diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 1d4df828f9..66085a52f0 100644 --- a/cli/src/api/open-api/api.ts +++ b/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 => { + 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>> { + 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> { 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> { + 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. diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e83d475be3..889f20658d 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 8b4581d0a8..251ca458b3 100644 Binary files a/mobile/openapi/doc/AssetApi.md and b/mobile/openapi/doc/AssetApi.md differ diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index ac828f730b..c2c5fd021c 100644 Binary files a/mobile/openapi/lib/api/asset_api.dart and b/mobile/openapi/lib/api/asset_api.dart differ diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index d374f212ef..c300501986 100644 Binary files a/mobile/openapi/test/asset_api_test.dart and b/mobile/openapi/test/asset_api_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index ce637df63e..825b707c60 100644 --- a/server/immich-openapi-specs.json +++ b/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": { "post": { "operationId": "searchAsset", diff --git a/server/src/domain/asset/asset.repository.ts b/server/src/domain/asset/asset.repository.ts index 3eb47de026..88af992fd6 100644 --- a/server/src/domain/asset/asset.repository.ts +++ b/server/src/domain/asset/asset.repository.ts @@ -78,6 +78,7 @@ export interface IAssetRepository { getByUserId(pagination: PaginationOptions, userId: string): Paginated; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated; + getRandom(userId: string, count: number): Promise; getFirstAssetForAlbumId(albumId: string): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; getByLibraryId(libraryIds: string[]): Promise; diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 90f8196c9c..c70cbe006b 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -284,6 +284,11 @@ export class AssetService { return mapStats(stats); } + async getRandom(authUser: AuthUserDto, count: number): Promise { + const assets = await this.assetRepository.getRandom(authUser.id, count); + return assets.map((a) => mapAsset(a)); + } + async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise { await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id); diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index f6ba61b20b..c10924ff6c 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/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 { BulkIdsDto } from '../response-dto'; @@ -25,3 +26,11 @@ export class UpdateAssetDto { @IsString() description?: string; } + +export class RandomAssetsDto { + @Optional() + @IsInt() + @IsPositive() + @Type(() => Number) + count?: number; +} diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index 2e6bb47742..f3bd9747ed 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -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 { + return this.service.getRandom(authUser, dto.count ?? 1); + } + @SharedLinkRoute() @Post('download/info') getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Body() dto: DownloadInfoDto): Promise { diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 1d0044fa62..2d2ea6563f 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -429,6 +429,17 @@ export class AssetRepository implements IAssetRepository { return result; } + getRandom(ownerId: string, count: number): Promise { + // 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 { const truncateValue = truncateMap[options.size]; diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index 24939d27b9..8fe2a62e19 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/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 { 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 }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index b3fde02a54..1a13ef2c57 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -11,6 +11,7 @@ export const newAssetRepositoryMock = (): jest.Mocked => { 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 }), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 1d4df828f9..66085a52f0 100644 --- a/web/src/api/open-api/api.ts +++ b/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 => { + 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>> { + 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> { 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> { + 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.