mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00: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:
parent
fc64be6603
commit
014d164d99
14 changed files with 429 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue