mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
refactor(server): asset stats (#3253)
* refactor(server): asset stats * chore: open api
This commit is contained in:
parent
05e1a6d949
commit
f952bc0b64
29 changed files with 601 additions and 844 deletions
|
|
@ -1,6 +1,13 @@
|
|||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||
import { Paginated, PaginationOptions } from '../domain.util';
|
||||
|
||||
export type AssetStats = Record<AssetType, number>;
|
||||
|
||||
export interface AssetStatsOptions {
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
export interface AssetSearchOptions {
|
||||
isVisible?: boolean;
|
||||
type?: AssetType;
|
||||
|
|
@ -55,4 +62,5 @@ export interface IAssetRepository {
|
|||
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
|
||||
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
|
||||
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
|
||||
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { AssetType } from '@app/infra/entities';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
assetEntityStub,
|
||||
|
|
@ -10,9 +11,9 @@ import {
|
|||
import { when } from 'jest-when';
|
||||
import { Readable } from 'stream';
|
||||
import { IStorageRepository } from '../storage';
|
||||
import { IAssetRepository } from './asset.repository';
|
||||
import { AssetStats, IAssetRepository } from './asset.repository';
|
||||
import { AssetService } from './asset.service';
|
||||
import { DownloadResponseDto } from './index';
|
||||
import { AssetStatsResponseDto, DownloadResponseDto } from './dto';
|
||||
import { mapAsset } from './response-dto';
|
||||
|
||||
const downloadResponse: DownloadResponseDto = {
|
||||
|
|
@ -25,6 +26,19 @@ const downloadResponse: DownloadResponseDto = {
|
|||
],
|
||||
};
|
||||
|
||||
const stats: AssetStats = {
|
||||
[AssetType.IMAGE]: 10,
|
||||
[AssetType.VIDEO]: 23,
|
||||
[AssetType.AUDIO]: 0,
|
||||
[AssetType.OTHER]: 0,
|
||||
};
|
||||
|
||||
const statResponse: AssetStatsResponseDto = {
|
||||
images: 10,
|
||||
videos: 23,
|
||||
total: 33,
|
||||
};
|
||||
|
||||
describe(AssetService.name, () => {
|
||||
let sut: AssetService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
|
|
@ -287,4 +301,30 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatistics', () => {
|
||||
it('should get the statistics for a user, excluding archived assets', async () => {
|
||||
assetMock.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, { isArchived: false })).resolves.toEqual(statResponse);
|
||||
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, { isArchived: false });
|
||||
});
|
||||
|
||||
it('should get the statistics for a user for archived assets', async () => {
|
||||
assetMock.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, { isArchived: true })).resolves.toEqual(statResponse);
|
||||
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, { isArchived: true });
|
||||
});
|
||||
|
||||
it('should get the statistics for a user for favorite assets', async () => {
|
||||
assetMock.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse);
|
||||
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, { isFavorite: true });
|
||||
});
|
||||
|
||||
it('should get the statistics for a user for all assets', async () => {
|
||||
assetMock.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse);
|
||||
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, {});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { HumanReadableSize, usePagination } from '../domain.util';
|
|||
import { ImmichReadStream, IStorageRepository } from '../storage';
|
||||
import { IAssetRepository } from './asset.repository';
|
||||
import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto';
|
||||
import { AssetStatsDto, mapStats } from './dto/asset-statistics.dto';
|
||||
import { MapMarkerDto } from './dto/map-marker.dto';
|
||||
import { mapAsset, MapMarkerResponseDto } from './response-dto';
|
||||
import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto';
|
||||
|
|
@ -155,4 +156,9 @@ export class AssetService {
|
|||
|
||||
throw new BadRequestException('assetIds, albumId, or userId is required');
|
||||
}
|
||||
|
||||
async getStatistics(authUser: AuthUserDto, dto: AssetStatsDto) {
|
||||
const stats = await this.assetRepository.getStatistics(authUser.id, dto);
|
||||
return mapStats(stats);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
server/src/domain/asset/dto/asset-statistics.dto.ts
Normal file
37
server/src/domain/asset/dto/asset-statistics.dto.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { AssetType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { toBoolean } from '../../domain.util';
|
||||
import { AssetStats } from '../asset.repository';
|
||||
|
||||
export class AssetStatsDto {
|
||||
@IsBoolean()
|
||||
@Transform(toBoolean)
|
||||
@IsOptional()
|
||||
isArchived?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@Transform(toBoolean)
|
||||
@IsOptional()
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export class AssetStatsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
images!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
videos!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
total!: number;
|
||||
}
|
||||
|
||||
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
|
||||
return {
|
||||
images: stats[AssetType.IMAGE],
|
||||
videos: stats[AssetType.VIDEO],
|
||||
total: Object.values(stats).reduce((total, value) => total + value, 0),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './asset-ids.dto';
|
||||
export * from './asset-statistics.dto';
|
||||
export * from './download.dto';
|
||||
export * from './map-marker.dto';
|
||||
export * from './memory-lane.dto';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
||||
import { AssetEntity, ExifEntity } from '@app/infra/entities';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
|
|
@ -11,7 +11,6 @@ import { GetAssetCountByTimeBucketDto, TimeGroupEnum } from './dto/get-asset-cou
|
|||
import { SearchPropertiesDto } from './dto/search-properties.dto';
|
||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
|
||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||
|
||||
|
|
@ -38,8 +37,6 @@ export interface IAssetRepository {
|
|||
getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
|
||||
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
|
||||
getAssetCountByTimeBucket(userId: string, dto: GetAssetCountByTimeBucketDto): Promise<AssetCountByTimeBucket[]>;
|
||||
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
|
||||
getArchivedAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
|
||||
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
|
||||
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
|
||||
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
|
||||
|
|
@ -55,35 +52,6 @@ export class AssetRepository implements IAssetRepository {
|
|||
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
|
||||
) {}
|
||||
|
||||
async getAssetCountByUserId(ownerId: string): Promise<AssetCountByUserIdResponseDto> {
|
||||
// Get asset count by AssetType
|
||||
const items = await this.assetRepository
|
||||
.createQueryBuilder('asset')
|
||||
.select(`COUNT(asset.id)`, 'count')
|
||||
.addSelect(`asset.type`, 'type')
|
||||
.where('"ownerId" = :ownerId', { ownerId: ownerId })
|
||||
.andWhere('asset.isVisible = true')
|
||||
.groupBy('asset.type')
|
||||
.getRawMany();
|
||||
|
||||
return this.getAssetCount(items);
|
||||
}
|
||||
|
||||
async getArchivedAssetCountByUserId(ownerId: string): Promise<AssetCountByUserIdResponseDto> {
|
||||
// Get archived asset count by AssetType
|
||||
const items = await this.assetRepository
|
||||
.createQueryBuilder('asset')
|
||||
.select(`COUNT(asset.id)`, 'count')
|
||||
.addSelect(`asset.type`, 'type')
|
||||
.where('"ownerId" = :ownerId', { ownerId: ownerId })
|
||||
.andWhere('asset.isVisible = true')
|
||||
.andWhere('asset.isArchived = true')
|
||||
.groupBy('asset.type')
|
||||
.getRawMany();
|
||||
|
||||
return this.getAssetCount(items);
|
||||
}
|
||||
|
||||
async getAssetByTimeBucket(userId: string, dto: GetAssetByTimeBucketDto): Promise<AssetEntity[]> {
|
||||
// Get asset entity from a list of time buckets
|
||||
let builder = this.assetRepository
|
||||
|
|
@ -337,29 +305,6 @@ export class AssetRepository implements IAssetRepository {
|
|||
return assets.map((asset) => asset.deviceAssetId);
|
||||
}
|
||||
|
||||
private getAssetCount(items: any): AssetCountByUserIdResponseDto {
|
||||
const assetCountByUserId = new AssetCountByUserIdResponseDto();
|
||||
|
||||
// asset type to dto property mapping
|
||||
const map: Record<AssetType, keyof AssetCountByUserIdResponseDto> = {
|
||||
[AssetType.AUDIO]: 'audio',
|
||||
[AssetType.IMAGE]: 'photos',
|
||||
[AssetType.VIDEO]: 'videos',
|
||||
[AssetType.OTHER]: 'other',
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const count = Number(item.count) || 0;
|
||||
const assetType = item.type as AssetType;
|
||||
const type = map[assetType];
|
||||
|
||||
assetCountByUserId[type] = count;
|
||||
assetCountByUserId.total += count;
|
||||
}
|
||||
|
||||
return assetCountByUserId;
|
||||
}
|
||||
|
||||
getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null> {
|
||||
return this.assetRepository.findOne({
|
||||
select: {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ import { ServeFileDto } from './dto/serve-file.dto';
|
|||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||
import { AssetBulkUploadCheckResponseDto } from './response-dto/asset-check-response.dto';
|
||||
import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
|
||||
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
|
||||
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
|
||||
|
|
@ -173,15 +172,6 @@ export class AssetController {
|
|||
return this.assetService.getAssetCountByTimeBucket(authUser, dto);
|
||||
}
|
||||
|
||||
@Get('/count-by-user-id')
|
||||
getAssetCountByUserId(@AuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
|
||||
return this.assetService.getAssetCountByUserId(authUser);
|
||||
}
|
||||
|
||||
@Get('/stat/archive')
|
||||
getArchivedAssetCountByUserId(@AuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
|
||||
return this.assetService.getArchivedAssetCountByUserId(authUser);
|
||||
}
|
||||
/**
|
||||
* Get all AssetEntity belong to the user
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import { CreateAssetDto } from './dto/create-asset.dto';
|
|||
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
import { AssetRejectReason, AssetUploadAction } from './response-dto/asset-check-response.dto';
|
||||
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
|
||||
const _getCreateAssetDto = (): CreateAssetDto => {
|
||||
const createAssetDto = new CreateAssetDto();
|
||||
|
|
@ -103,24 +102,6 @@ const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
|
|||
return [result1, result2];
|
||||
};
|
||||
|
||||
const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
|
||||
const result = new AssetCountByUserIdResponseDto();
|
||||
|
||||
result.videos = 2;
|
||||
result.photos = 2;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const _getArchivedAssetsCountByUserId = (): AssetCountByUserIdResponseDto => {
|
||||
const result = new AssetCountByUserIdResponseDto();
|
||||
|
||||
result.videos = 1;
|
||||
result.photos = 2;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const uploadFile = {
|
||||
nullAuth: {
|
||||
authUser: null,
|
||||
|
|
@ -197,8 +178,6 @@ describe('AssetService', () => {
|
|||
getSearchPropertiesByUserId: jest.fn(),
|
||||
getAssetByTimeBucket: jest.fn(),
|
||||
getAssetsByChecksums: jest.fn(),
|
||||
getAssetCountByUserId: jest.fn(),
|
||||
getArchivedAssetCountByUserId: jest.fn(),
|
||||
getExistingAssets: jest.fn(),
|
||||
getByOriginalPath: jest.fn(),
|
||||
};
|
||||
|
|
@ -467,20 +446,6 @@ describe('AssetService', () => {
|
|||
expect(result.buckets.length).toEqual(2);
|
||||
});
|
||||
|
||||
it('get asset count by user id', async () => {
|
||||
const assetCount = _getAssetCountByUserId();
|
||||
assetRepositoryMock.getAssetCountByUserId.mockResolvedValue(assetCount);
|
||||
|
||||
await expect(sut.getAssetCountByUserId(authStub.user1)).resolves.toEqual(assetCount);
|
||||
});
|
||||
|
||||
it('get archived asset count by user id', async () => {
|
||||
const assetCount = _getArchivedAssetsCountByUserId();
|
||||
assetRepositoryMock.getArchivedAssetCountByUserId.mockResolvedValue(assetCount);
|
||||
|
||||
await expect(sut.getArchivedAssetCountByUserId(authStub.user1)).resolves.toEqual(assetCount);
|
||||
});
|
||||
|
||||
describe('deleteAll', () => {
|
||||
it('should return failed status when an asset is missing', async () => {
|
||||
assetRepositoryMock.get.mockResolvedValue(null);
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ import {
|
|||
AssetCountByTimeBucketResponseDto,
|
||||
mapAssetCountByTimeBucket,
|
||||
} from './response-dto/asset-count-by-time-group-response.dto';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
|
||||
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
|
||||
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
|
||||
|
|
@ -536,14 +535,6 @@ export class AssetService {
|
|||
return mapAssetCountByTimeBucket(result);
|
||||
}
|
||||
|
||||
getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
|
||||
return this._assetRepository.getAssetCountByUserId(authUser.id);
|
||||
}
|
||||
|
||||
getArchivedAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
|
||||
return this._assetRepository.getArchivedAssetCountByUserId(authUser.id);
|
||||
}
|
||||
|
||||
getExifPermission(authUser: AuthUserDto) {
|
||||
return !authUser.isPublicUser || authUser.isShowExif;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AssetCountByUserIdResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
audio = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
photos = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
videos = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
other = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
total = 0;
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
AssetIdsDto,
|
||||
AssetService,
|
||||
AssetStatsDto,
|
||||
AssetStatsResponseDto,
|
||||
AuthUserDto,
|
||||
DownloadDto,
|
||||
DownloadResponseDto,
|
||||
|
|
@ -53,4 +55,9 @@ export class AssetController {
|
|||
downloadFile(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
|
||||
return this.service.downloadFile(authUser, id).then(asStreamableFile);
|
||||
}
|
||||
|
||||
@Get('statistics')
|
||||
getAssetStats(@AuthUser() authUser: AuthUserDto, @Query() dto: AssetStatsDto): Promise<AssetStatsResponseDto> {
|
||||
return this.service.getStatistics(authUser, dto);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import {
|
||||
AssetSearchOptions,
|
||||
AssetStats,
|
||||
AssetStatsOptions,
|
||||
IAssetRepository,
|
||||
LivePhotoSearchOptions,
|
||||
MapMarker,
|
||||
|
|
@ -321,4 +323,38 @@ export class AssetRepository implements IAssetRepository {
|
|||
lon: asset.exifInfo!.longitude!,
|
||||
}));
|
||||
}
|
||||
|
||||
async getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats> {
|
||||
let builder = await this.repository
|
||||
.createQueryBuilder('asset')
|
||||
.select(`COUNT(asset.id)`, 'count')
|
||||
.addSelect(`asset.type`, 'type')
|
||||
.where('"ownerId" = :ownerId', { ownerId })
|
||||
.andWhere('asset.isVisible = true')
|
||||
.groupBy('asset.type');
|
||||
|
||||
const { isArchived, isFavorite } = options;
|
||||
if (isArchived !== undefined) {
|
||||
builder = builder.andWhere(`asset.isArchived = :isArchived`, { isArchived });
|
||||
}
|
||||
|
||||
if (isFavorite !== undefined) {
|
||||
builder = builder.andWhere(`asset.isFavorite = :isFavorite`, { isFavorite });
|
||||
}
|
||||
|
||||
const items = await builder.getRawMany();
|
||||
|
||||
const result: AssetStats = {
|
||||
[AssetType.AUDIO]: 0,
|
||||
[AssetType.IMAGE]: 0,
|
||||
[AssetType.VIDEO]: 0,
|
||||
[AssetType.OTHER]: 0,
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
result[item.type as AssetType] = Number(item.count) || 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue