1
0
mirror of https://github.com/immich-app/immich.git synced 2025-08-10 23:22:22 +02:00

feat(web): add asset and album count info (#623)

* Get asset and album count

* Generate APIs

* Added asset count for each type

* Added api on the web

* Added info button for asset and album count to trigger getting info on hover

* Remove websocket event from photo page
This commit is contained in:
Alex
2022-09-07 15:16:18 -05:00
committed by GitHub
parent 18a7ff8726
commit 566039b93f
28 changed files with 932 additions and 39 deletions

View File

@@ -10,6 +10,7 @@ import { CreateAlbumDto } from './dto/create-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AlbumResponseDto } from './response-dto/album-response.dto';
export interface IAlbumRepository {
@@ -23,6 +24,7 @@ export interface IAlbumRepository {
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>;
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
}
export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
@@ -42,6 +44,18 @@ export class AlbumRepository implements IAlbumRepository {
private dataSource: DataSource,
) {}
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] });
const sharedAlbums = await this.userAlbumRepository.count({
where: { sharedUserId: userId },
});
const sharingAlbums = ownedAlbums.map((album) => album.sharedUsers?.length || 0).reduce((a, b) => a + b, 0);
return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharingAlbums);
}
async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {
return this.dataSource.transaction(async (transactionalEntityManager) => {
// Create album entity
@@ -154,23 +168,23 @@ export class AlbumRepository implements IAlbumRepository {
let query = this.albumRepository.createQueryBuilder('album');
const albums = await query
.where('album.ownerId = :ownerId', { ownerId: userId })
.andWhere((qb) => {
// shared with userId
const subQuery = qb
.subQuery()
.select('assetAlbum.albumId')
.from(AssetAlbumEntity, 'assetAlbum')
.where('assetAlbum.assetId = :assetId', {assetId: assetId})
.getQuery();
return `album.id IN ${subQuery}`;
})
.leftJoinAndSelect('album.assets', 'assets')
.leftJoinAndSelect('assets.assetInfo', 'assetInfo')
.leftJoinAndSelect('album.sharedUsers', 'sharedUser')
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
.getMany();
.where('album.ownerId = :ownerId', { ownerId: userId })
.andWhere((qb) => {
// shared with userId
const subQuery = qb
.subQuery()
.select('assetAlbum.albumId')
.from(AssetAlbumEntity, 'assetAlbum')
.where('assetAlbum.assetId = :assetId', { assetId: assetId })
.getQuery();
return `album.id IN ${subQuery}`;
})
.leftJoinAndSelect('album.assets', 'assets')
.leftJoinAndSelect('assets.assetInfo', 'assetInfo')
.leftJoinAndSelect('album.sharedUsers', 'sharedUser')
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
.getMany();
return albums;
}
@@ -228,9 +242,10 @@ export class AlbumRepository implements IAlbumRepository {
// TODO: No need to return boolean if using a singe delete query
if (deleteAssetCount == removeAssetsDto.assetIds.length) {
const retAlbum = await this.get(album.id) as AlbumEntity;
const retAlbum = (await this.get(album.id)) as AlbumEntity;
if (retAlbum?.assets?.length === 0) { // is empty album
if (retAlbum?.assets?.length === 0) {
// is empty album
await this.albumRepository.update(album.id, { albumThumbnailAssetId: null });
retAlbum.albumThumbnailAssetId = null;
}

View File

@@ -11,6 +11,7 @@ import {
ParseUUIDPipe,
Put,
Query,
Header,
} from '@nestjs/common';
import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
import { AlbumService } from './album.service';
@@ -24,6 +25,7 @@ import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AlbumResponseDto } from './response-dto/album-response.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
@UseGuards(JwtAuthGuard)
@@ -33,6 +35,11 @@ import { AlbumResponseDto } from './response-dto/album-response.dto';
export class AlbumController {
constructor(private readonly albumService: AlbumService) {}
@Get('count-by-user-id')
async getAlbumCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this.albumService.getAlbumCountByUserId(authUser);
}
@Post()
async createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) {
return this.albumService.create(authUser, createAlbumDto);

View File

@@ -116,7 +116,8 @@ describe('Album service', () => {
removeAssets: jest.fn(),
removeUser: jest.fn(),
updateAlbum: jest.fn(),
getListByAssetId: jest.fn()
getListByAssetId: jest.fn(),
getCountByUserId: jest.fn(),
};
sut = new AlbumService(albumRepositoryMock);
});

View File

@@ -9,6 +9,7 @@ import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
@Injectable()
export class AlbumService {
@@ -118,4 +119,8 @@ export class AlbumService {
const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto);
return mapAlbum(updatedAlbum);
}
async getAlbumCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this._albumRepository.getCountByUserId(authUser.id);
}
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
export class AlbumCountResponseDto {
@ApiProperty({ type: 'integer' })
owned!: number;
@ApiProperty({ type: 'integer' })
shared!: number;
@ApiProperty({ type: 'integer' })
sharing!: number;
constructor(owned: number, shared: number, sharing: number) {
this.owned = owned;
this.shared = shared;
this.sharing = sharing;
}
}

View File

@@ -9,6 +9,7 @@ import { CuratedObjectsResponseDto } from './response-dto/curated-objects-respon
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
export interface IAssetRepository {
create(
@@ -25,6 +26,7 @@ export interface IAssetRepository {
getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
getAssetCountByTimeBucket(userId: string, timeBucket: TimeGroupEnum): Promise<AssetCountByTimeBucket[]>;
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
}
@@ -38,6 +40,28 @@ export class AssetRepository implements IAssetRepository {
private assetRepository: Repository<AssetEntity>,
) {}
async getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto> {
// Get asset count by AssetType
const res = await this.assetRepository
.createQueryBuilder('asset')
.select(`COUNT(asset.id)`, 'count')
.addSelect(`asset.type`, 'type')
.where('"userId" = :userId', { userId: userId })
.groupBy('asset.type')
.getRawMany();
const assetCountByUserId = new AssetCountByUserIdResponseDto(0, 0);
res.map((item) => {
if (item.type === 'IMAGE') {
assetCountByUserId.photos = item.count;
} else if (item.type === 'VIDEO') {
assetCountByUserId.videos = item.count;
}
});
return assetCountByUserId;
}
async getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]> {
// Get asset entity from a list of time buckets
return await this.assetRepository

View File

@@ -48,6 +48,7 @@ import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { QueryFailedError } from 'typeorm';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@@ -78,7 +79,13 @@ export class AssetController {
const checksum = await this.assetService.calculateChecksum(file.path);
try {
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype, checksum);
const savedAsset = await this.assetService.createUserAsset(
authUser,
assetInfo,
file.path,
file.mimetype,
checksum,
);
if (!savedAsset) {
await this.backgroundTaskService.deleteFileOnDisk([
@@ -104,7 +111,7 @@ export class AssetController {
]); // simulate asset to make use of delete queue (or use fs.unlink instead)
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum)
const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum);
return new AssetFileUploadResponseDto(existedAsset.id);
}
@@ -172,6 +179,10 @@ export class AssetController {
return this.assetService.getAssetCountByTimeBucket(authUser, getAssetCountByTimeGroupDto);
}
@Get('/count-by-user-id')
async getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this.assetService.getAssetCountByUserId(authUser);
}
/**
* Get all AssetEntity belong to the user
*/

View File

@@ -61,6 +61,7 @@ describe('AssetService', () => {
getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(),
};
sui = new AssetService(assetRepositoryMock, a);

View File

@@ -35,6 +35,7 @@ import {
} from './response-dto/asset-count-by-time-group-response.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
const fileInfo = promisify(stat);
@@ -479,4 +480,8 @@ export class AssetService {
fileReadStream.pipe(sha1Hash);
return deferred;
}
getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this._assetRepository.getAssetCountByUserId(authUser.id);
}
}

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
export class AssetCountByUserIdResponseDto {
@ApiProperty({ type: 'integer' })
photos!: number;
@ApiProperty({ type: 'integer' })
videos!: number;
constructor(photos: number, videos: number) {
this.photos = photos;
this.videos = videos;
}
}

File diff suppressed because one or more lines are too long