You've already forked immich
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:
@@ -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;
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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);
|
||||
});
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
@@ -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
|
||||
*/
|
||||
|
@@ -61,6 +61,7 @@ describe('AssetService', () => {
|
||||
getSearchPropertiesByUserId: jest.fn(),
|
||||
getAssetByTimeBucket: jest.fn(),
|
||||
getAssetByChecksum: jest.fn(),
|
||||
getAssetCountByUserId: jest.fn(),
|
||||
};
|
||||
|
||||
sui = new AssetService(assetRepositoryMock, a);
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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
Reference in New Issue
Block a user