Browse Source

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
Daniele Ricci 1 year ago
parent
commit
014d164d99

+ 87 - 0
cli/src/api/open-api/api.ts

@@ -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);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -7043,6 +7086,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             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 
          * @param {TimeBucketSize} size 
@@ -7318,6 +7371,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig): AxiosPromise<Array<MemoryLaneResponseDto>> {
         getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig): AxiosPromise<Array<MemoryLaneResponseDto>> {
             return localVarFp.getMemoryLane(requestParameters.timestamp, options).then((request) => request(axios, basePath));
             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.
          * @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.
@@ -7752,6 +7814,20 @@ export interface AssetApiGetMemoryLaneRequest {
     readonly timestamp: string
     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.
  * Request parameters for getTimeBuckets operation in AssetApi.
  * @export
  * @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));
         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.
      * @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.

+ 1 - 0
mobile/openapi/README.md

@@ -104,6 +104,7 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**getDownloadInfo**](doc//AssetApi.md#getdownloadinfo) | **POST** /asset/download/info | 
 *AssetApi* | [**getDownloadInfo**](doc//AssetApi.md#getdownloadinfo) | **POST** /asset/download/info | 
 *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
 *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
+*AssetApi* | [**getRandom**](doc//AssetApi.md#getrandom) | **GET** /asset/random | 
 *AssetApi* | [**getTimeBuckets**](doc//AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 *AssetApi* | [**getTimeBuckets**](doc//AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 *AssetApi* | [**importFile**](doc//AssetApi.md#importfile) | **POST** /asset/import | 
 *AssetApi* | [**importFile**](doc//AssetApi.md#importfile) | **POST** /asset/import | 

+ 56 - 0
mobile/openapi/doc/AssetApi.md

@@ -26,6 +26,7 @@ Method | HTTP request | Description
 [**getDownloadInfo**](AssetApi.md#getdownloadinfo) | **POST** /asset/download/info | 
 [**getDownloadInfo**](AssetApi.md#getdownloadinfo) | **POST** /asset/download/info | 
 [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
 [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
+[**getRandom**](AssetApi.md#getrandom) | **GET** /asset/random | 
 [**getTimeBuckets**](AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 [**getTimeBuckets**](AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 [**importFile**](AssetApi.md#importfile) | **POST** /asset/import | 
 [**importFile**](AssetApi.md#importfile) | **POST** /asset/import | 
@@ -1014,6 +1015,61 @@ Name | Type | Description  | Notes
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
+# **getRandom**
+> List<AssetResponseDto> getRandom(count)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AssetApi();
+final count = 8.14; // num | 
+
+try {
+    final result = api_instance.getRandom(count);
+    print(result);
+} catch (e) {
+    print('Exception when calling AssetApi->getRandom: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **count** | **num**|  | [optional] 
+
+### Return type
+
+[**List<AssetResponseDto>**](AssetResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **getTimeBuckets**
 # **getTimeBuckets**
 > List<TimeBucketResponseDto> getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key)
 > List<TimeBucketResponseDto> getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key)
 
 

+ 54 - 0
mobile/openapi/lib/api/asset_api.dart

@@ -1028,6 +1028,60 @@ class AssetApi {
     return null;
     return null;
   }
   }
 
 
+  /// Performs an HTTP 'GET /asset/random' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [num] count:
+  Future<Response> getRandomWithHttpInfo({ num? count, }) async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset/random';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    if (count != null) {
+      queryParams.addAll(_queryParams('', 'count', count));
+    }
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [num] count:
+  Future<List<AssetResponseDto>?> getRandom({ num? count, }) async {
+    final response = await getRandomWithHttpInfo( count: count, );
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
+        .cast<AssetResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'GET /asset/time-buckets' operation and returns the [Response].
   /// Performs an HTTP 'GET /asset/time-buckets' operation and returns the [Response].
   /// Parameters:
   /// Parameters:
   ///
   ///

+ 5 - 0
mobile/openapi/test/asset_api_test.dart

@@ -112,6 +112,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    //Future<List<AssetResponseDto>> getRandom({ num count }) async
+    test('test getRandom', () async {
+      // TODO
+    });
+
     //Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, String key }) async
     //Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, String key }) async
     test('test getTimeBuckets', () async {
     test('test getTimeBuckets', () async {
       // TODO
       // TODO

+ 44 - 0
server/immich-openapi-specs.json

@@ -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": {
     "/asset/search": {
       "post": {
       "post": {
         "operationId": "searchAsset",
         "operationId": "searchAsset",

+ 1 - 0
server/src/domain/asset/asset.repository.ts

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

+ 5 - 0
server/src/domain/asset/asset.service.ts

@@ -284,6 +284,11 @@ export class AssetService {
     return mapStats(stats);
     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> {
   async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id);
     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id);
 
 

+ 10 - 1
server/src/domain/asset/dto/asset.dto.ts

@@ -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 { Optional } from '../../domain.util';
 import { BulkIdsDto } from '../response-dto';
 import { BulkIdsDto } from '../response-dto';
 
 
@@ -25,3 +26,11 @@ export class UpdateAssetDto {
   @IsString()
   @IsString()
   description?: string;
   description?: string;
 }
 }
+
+export class RandomAssetsDto {
+  @Optional()
+  @IsInt()
+  @IsPositive()
+  @Type(() => Number)
+  count?: number;
+}

+ 6 - 0
server/src/immich/controllers/asset.controller.ts

@@ -13,6 +13,7 @@ import {
   MapMarkerResponseDto,
   MapMarkerResponseDto,
   MemoryLaneDto,
   MemoryLaneDto,
   MemoryLaneResponseDto,
   MemoryLaneResponseDto,
+  RandomAssetsDto,
   TimeBucketAssetDto,
   TimeBucketAssetDto,
   TimeBucketDto,
   TimeBucketDto,
   TimeBucketResponseDto,
   TimeBucketResponseDto,
@@ -41,6 +42,11 @@ export class AssetController {
     return this.service.getMemoryLane(authUser, dto);
     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()
   @SharedLinkRoute()
   @Post('download/info')
   @Post('download/info')
   getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Body() dto: DownloadInfoDto): Promise<DownloadResponseDto> {
   getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Body() dto: DownloadInfoDto): Promise<DownloadResponseDto> {

+ 11 - 0
server/src/infra/repositories/asset.repository.ts

@@ -429,6 +429,17 @@ export class AssetRepository implements IAssetRepository {
     return result;
     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[]> {
   getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
     const truncateValue = truncateMap[options.size];
     const truncateValue = truncateMap[options.size];
 
 

+ 61 - 2
server/test/e2e/asset.e2e-spec.ts

@@ -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 { AppModule, AssetController } from '@app/immich';
 import { AssetEntity, AssetType } from '@app/infra/entities';
 import { AssetEntity, AssetType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
@@ -322,7 +329,7 @@ describe(`${AssetController.name} (e2e)`, () => {
     });
     });
 
 
     it('should require authentication', async () => {
     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(status).toBe(401);
       expect(body).toEqual(errorStub.unauthorized);
       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', () => {
   describe('GET /asset/time-buckets', () => {
     it('should require authentication', async () => {
     it('should require authentication', async () => {
       const { status, body } = await request(server).get('/asset/time-buckets').query({ size: TimeBucketSize.MONTH });
       const { status, body } = await request(server).get('/asset/time-buckets').query({ size: TimeBucketSize.MONTH });

+ 1 - 0
server/test/repositories/asset.repository.mock.ts

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

+ 87 - 0
web/src/api/open-api/api.ts

@@ -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);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -7043,6 +7086,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             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 
          * @param {TimeBucketSize} size 
@@ -7318,6 +7371,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig): AxiosPromise<Array<MemoryLaneResponseDto>> {
         getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig): AxiosPromise<Array<MemoryLaneResponseDto>> {
             return localVarFp.getMemoryLane(requestParameters.timestamp, options).then((request) => request(axios, basePath));
             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.
          * @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.
@@ -7752,6 +7814,20 @@ export interface AssetApiGetMemoryLaneRequest {
     readonly timestamp: string
     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.
  * Request parameters for getTimeBuckets operation in AssetApi.
  * @export
  * @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));
         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.
      * @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.