mirror of
https://github.com/immich-app/immich.git
synced 2025-01-04 02:46:47 +01:00
feat(server): improve and refactor get all albums (#2048)
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
2400004f41
commit
c74fba483d
23 changed files with 1026 additions and 751 deletions
BIN
mobile/openapi/doc/AlbumApi.md
generated
BIN
mobile/openapi/doc/AlbumApi.md
generated
Binary file not shown.
|
@ -1,11 +1,10 @@
|
||||||
import { AlbumEntity, AssetEntity, dataSource, UserEntity } from '@app/infra';
|
import { AlbumEntity, AssetEntity, dataSource, UserEntity } from '@app/infra';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, Not, IsNull, FindManyOptions } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||||
import { AddUsersDto } from './dto/add-users.dto';
|
import { AddUsersDto } from './dto/add-users.dto';
|
||||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
|
||||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||||
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
||||||
|
@ -13,8 +12,6 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
||||||
|
|
||||||
export interface IAlbumRepository {
|
export interface IAlbumRepository {
|
||||||
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
|
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
|
||||||
getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]>;
|
|
||||||
getPublicSharingList(ownerId: string): Promise<AlbumEntity[]>;
|
|
||||||
get(albumId: string): Promise<AlbumEntity | null>;
|
get(albumId: string): Promise<AlbumEntity | null>;
|
||||||
delete(album: AlbumEntity): Promise<void>;
|
delete(album: AlbumEntity): Promise<void>;
|
||||||
addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
|
addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
|
||||||
|
@ -23,7 +20,6 @@ export interface IAlbumRepository {
|
||||||
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
|
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
|
||||||
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
|
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
|
||||||
updateThumbnails(): Promise<number | undefined>;
|
updateThumbnails(): Promise<number | undefined>;
|
||||||
getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>;
|
|
||||||
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
|
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
|
||||||
getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number>;
|
getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number>;
|
||||||
}
|
}
|
||||||
|
@ -40,22 +36,6 @@ export class AlbumRepository implements IAlbumRepository {
|
||||||
private assetRepository: Repository<AssetEntity>,
|
private assetRepository: Repository<AssetEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getPublicSharingList(ownerId: string): Promise<AlbumEntity[]> {
|
|
||||||
return this.albumRepository.find({
|
|
||||||
relations: {
|
|
||||||
sharedLinks: true,
|
|
||||||
assets: true,
|
|
||||||
owner: true,
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
ownerId,
|
|
||||||
sharedLinks: {
|
|
||||||
id: Not(IsNull()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
|
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
|
||||||
const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] });
|
const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] });
|
||||||
const sharedAlbums = await this.albumRepository.count({ where: { sharedUsers: { id: userId } } });
|
const sharedAlbums = await this.albumRepository.count({ where: { sharedUsers: { id: userId } } });
|
||||||
|
@ -77,59 +57,6 @@ export class AlbumRepository implements IAlbumRepository {
|
||||||
return this.get(album.id) as Promise<AlbumEntity>;
|
return this.get(album.id) as Promise<AlbumEntity>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]> {
|
|
||||||
const filteringByShared = typeof getAlbumsDto.shared == 'boolean';
|
|
||||||
const userId = ownerId;
|
|
||||||
|
|
||||||
const queryProperties: FindManyOptions<AlbumEntity> = {
|
|
||||||
relations: { sharedUsers: true, assets: true, sharedLinks: true, owner: true },
|
|
||||||
select: { assets: { id: true } },
|
|
||||||
order: { createdAt: 'DESC' },
|
|
||||||
};
|
|
||||||
|
|
||||||
let albumsQuery: Promise<AlbumEntity[]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `shared` boolean usage
|
|
||||||
* true = shared with me, and my albums that are shared
|
|
||||||
* false = my albums that are not shared
|
|
||||||
* undefined = all my albums
|
|
||||||
*/
|
|
||||||
if (filteringByShared) {
|
|
||||||
if (getAlbumsDto.shared) {
|
|
||||||
// shared albums
|
|
||||||
albumsQuery = this.albumRepository.find({
|
|
||||||
where: [{ sharedUsers: { id: userId } }, { ownerId: userId, sharedUsers: { id: Not(IsNull()) } }],
|
|
||||||
...queryProperties,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// owned, not shared albums
|
|
||||||
albumsQuery = this.albumRepository.find({
|
|
||||||
where: { ownerId: userId, sharedUsers: { id: IsNull() } },
|
|
||||||
...queryProperties,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// owned
|
|
||||||
albumsQuery = this.albumRepository.find({
|
|
||||||
where: { ownerId: userId },
|
|
||||||
...queryProperties,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return albumsQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> {
|
|
||||||
const albums = await this.albumRepository.find({
|
|
||||||
where: { ownerId: userId },
|
|
||||||
relations: { owner: true, assets: true, sharedUsers: true },
|
|
||||||
order: { assets: { fileCreatedAt: 'ASC' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
return albums.filter((album) => album.assets.some((asset) => asset.id === assetId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(albumId: string): Promise<AlbumEntity | null> {
|
async get(albumId: string): Promise<AlbumEntity | null> {
|
||||||
return this.albumRepository.findOne({
|
return this.albumRepository.findOne({
|
||||||
where: { id: albumId },
|
where: { id: albumId },
|
||||||
|
|
|
@ -21,7 +21,6 @@ import { AddAssetsDto } from './dto/add-assets.dto';
|
||||||
import { AddUsersDto } from './dto/add-users.dto';
|
import { AddUsersDto } from './dto/add-users.dto';
|
||||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
|
||||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AlbumResponseDto } from '@app/domain';
|
import { AlbumResponseDto } from '@app/domain';
|
||||||
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
||||||
|
@ -74,15 +73,6 @@ export class AlbumController {
|
||||||
return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId);
|
return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Authenticated()
|
|
||||||
@Get()
|
|
||||||
async getAllAlbums(
|
|
||||||
@GetAuthUser() authUser: AuthUserDto,
|
|
||||||
@Query(new ValidationPipe({ transform: true })) query: GetAlbumsDto,
|
|
||||||
) {
|
|
||||||
return this.albumService.getAllAlbums(authUser, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Authenticated({ isShared: true })
|
@Authenticated({ isShared: true })
|
||||||
@Get('/:albumId')
|
@Get('/:albumId')
|
||||||
async getAlbumInfo(
|
async getAlbumInfo(
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
import { AlbumService } from './album.service';
|
import { AlbumService } from './album.service';
|
||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||||
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra';
|
import { AlbumEntity, UserEntity } from '@app/infra';
|
||||||
import { AlbumResponseDto, ICryptoRepository, IJobRepository, JobName, mapUser } from '@app/domain';
|
import { AlbumResponseDto, ICryptoRepository, IJobRepository, JobName, mapUser } from '@app/domain';
|
||||||
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
||||||
import { IAlbumRepository } from './album-repository';
|
import { IAlbumRepository } from './album-repository';
|
||||||
import { DownloadService } from '../../modules/download/download.service';
|
import { DownloadService } from '../../modules/download/download.service';
|
||||||
import { ISharedLinkRepository } from '@app/domain';
|
import { ISharedLinkRepository } from '@app/domain';
|
||||||
import {
|
import {
|
||||||
assetEntityStub,
|
|
||||||
newCryptoRepositoryMock,
|
newCryptoRepositoryMock,
|
||||||
newJobRepositoryMock,
|
newJobRepositoryMock,
|
||||||
newSharedLinkRepositoryMock,
|
newSharedLinkRepositoryMock,
|
||||||
|
@ -119,18 +118,15 @@ describe('Album service', () => {
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
albumRepositoryMock = {
|
albumRepositoryMock = {
|
||||||
getPublicSharingList: jest.fn(),
|
|
||||||
addAssets: jest.fn(),
|
addAssets: jest.fn(),
|
||||||
addSharedUsers: jest.fn(),
|
addSharedUsers: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
getList: jest.fn(),
|
|
||||||
removeAssets: jest.fn(),
|
removeAssets: jest.fn(),
|
||||||
removeUser: jest.fn(),
|
removeUser: jest.fn(),
|
||||||
updateAlbum: jest.fn(),
|
updateAlbum: jest.fn(),
|
||||||
updateThumbnails: jest.fn(),
|
updateThumbnails: jest.fn(),
|
||||||
getListByAssetId: jest.fn(),
|
|
||||||
getCountByUserId: jest.fn(),
|
getCountByUserId: jest.fn(),
|
||||||
getSharedWithUserAlbumCount: jest.fn(),
|
getSharedWithUserAlbumCount: jest.fn(),
|
||||||
};
|
};
|
||||||
|
@ -166,19 +162,6 @@ describe('Album service', () => {
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [albumEntity.id] } });
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [albumEntity.id] } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gets list of albums for auth user', async () => {
|
|
||||||
const ownedAlbum = _getOwnedAlbum();
|
|
||||||
const ownedSharedAlbum = _getOwnedSharedAlbum();
|
|
||||||
const sharedWithMeAlbum = _getSharedWithAuthUserAlbum();
|
|
||||||
const albums: AlbumEntity[] = [ownedAlbum, ownedSharedAlbum, sharedWithMeAlbum];
|
|
||||||
|
|
||||||
albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums));
|
|
||||||
|
|
||||||
const result = await sut.getAllAlbums(authUser, {});
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].id).toEqual(ownedAlbum.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gets an owned album', async () => {
|
it('gets an owned album', async () => {
|
||||||
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
||||||
|
|
||||||
|
@ -474,76 +457,4 @@ describe('Album service', () => {
|
||||||
),
|
),
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('counts assets correctly', async () => {
|
|
||||||
const albumEntity = new AlbumEntity();
|
|
||||||
|
|
||||||
albumEntity.ownerId = authUser.id;
|
|
||||||
albumEntity.owner = albumOwner;
|
|
||||||
albumEntity.id = albumId;
|
|
||||||
albumEntity.albumName = 'name';
|
|
||||||
albumEntity.createdAt = 'date';
|
|
||||||
albumEntity.sharedUsers = [];
|
|
||||||
albumEntity.assets = [
|
|
||||||
{
|
|
||||||
...assetEntityStub.image,
|
|
||||||
id: '3',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
albumEntity.albumThumbnailAssetId = null;
|
|
||||||
|
|
||||||
albumRepositoryMock.getList.mockImplementation(() => Promise.resolve([albumEntity]));
|
|
||||||
|
|
||||||
const result = await sut.getAllAlbums(authUser, {});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].assetCount).toEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates the album thumbnail by listing all albums', async () => {
|
|
||||||
const albumEntity = _getOwnedAlbum();
|
|
||||||
const assetEntity = new AssetEntity();
|
|
||||||
const newThumbnailAsset = new AssetEntity();
|
|
||||||
newThumbnailAsset.id = 'e5e65c02-b889-4f3c-afe1-a39a96d578ed';
|
|
||||||
|
|
||||||
albumEntity.albumThumbnailAssetId = 'nonexistent';
|
|
||||||
assetEntity.id = newThumbnailAsset.id;
|
|
||||||
albumEntity.assets = [assetEntity];
|
|
||||||
albumRepositoryMock.getList.mockImplementation(async () => [albumEntity]);
|
|
||||||
albumRepositoryMock.updateThumbnails.mockImplementation(async () => {
|
|
||||||
albumEntity.albumThumbnailAsset = newThumbnailAsset;
|
|
||||||
albumEntity.albumThumbnailAssetId = newThumbnailAsset.id;
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sut.getAllAlbums(authUser, {});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].albumThumbnailAssetId).toEqual(newThumbnailAsset.id);
|
|
||||||
expect(albumRepositoryMock.getList).toHaveBeenCalledTimes(1);
|
|
||||||
expect(albumRepositoryMock.updateThumbnails).toHaveBeenCalledTimes(1);
|
|
||||||
expect(albumRepositoryMock.getList).toHaveBeenCalledWith(albumEntity.ownerId, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes the thumbnail for an empty album', async () => {
|
|
||||||
const albumEntity = _getOwnedAlbum();
|
|
||||||
|
|
||||||
albumEntity.albumThumbnailAssetId = 'e5e65c02-b889-4f3c-afe1-a39a96d578ed';
|
|
||||||
albumRepositoryMock.getList.mockImplementation(async () => [albumEntity]);
|
|
||||||
albumRepositoryMock.updateThumbnails.mockImplementation(async () => {
|
|
||||||
albumEntity.albumThumbnailAsset = null;
|
|
||||||
albumEntity.albumThumbnailAssetId = null;
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sut.getAllAlbums(authUser, {});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].albumThumbnailAssetId).toBeNull();
|
|
||||||
expect(albumRepositoryMock.getList).toHaveBeenCalledTimes(1);
|
|
||||||
expect(albumRepositoryMock.updateThumbnails).toHaveBeenCalledTimes(1);
|
|
||||||
expect(albumRepositoryMock.getList).toHaveBeenCalledWith(albumEntity.ownerId, {});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,8 +5,7 @@ import { AlbumEntity, SharedLinkType } from '@app/infra';
|
||||||
import { AddUsersDto } from './dto/add-users.dto';
|
import { AddUsersDto } from './dto/add-users.dto';
|
||||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
import { AlbumResponseDto, IJobRepository, JobName, mapAlbum } from '@app/domain';
|
||||||
import { AlbumResponseDto, IJobRepository, JobName, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain';
|
|
||||||
import { IAlbumRepository } from './album-repository';
|
import { IAlbumRepository } from './album-repository';
|
||||||
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
||||||
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
||||||
|
@ -15,7 +14,6 @@ import { DownloadService } from '../../modules/download/download.service';
|
||||||
import { DownloadDto } from '../asset/dto/download-library.dto';
|
import { DownloadDto } from '../asset/dto/download-library.dto';
|
||||||
import { ShareCore, ISharedLinkRepository, mapSharedLink, SharedLinkResponseDto, ICryptoRepository } from '@app/domain';
|
import { ShareCore, ISharedLinkRepository, mapSharedLink, SharedLinkResponseDto, ICryptoRepository } from '@app/domain';
|
||||||
import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
|
import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
|
||||||
import _ from 'lodash';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AlbumService {
|
export class AlbumService {
|
||||||
|
@ -63,31 +61,6 @@ export class AlbumService {
|
||||||
return mapAlbum(albumEntity);
|
return mapAlbum(albumEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all shared album, including owned and shared one.
|
|
||||||
* @param authUser AuthUserDto
|
|
||||||
* @returns All Shared Album And Its Members
|
|
||||||
*/
|
|
||||||
async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
|
||||||
await this.albumRepository.updateThumbnails();
|
|
||||||
|
|
||||||
let albums: AlbumEntity[];
|
|
||||||
if (typeof getAlbumsDto.assetId === 'string') {
|
|
||||||
albums = await this.albumRepository.getListByAssetId(authUser.id, getAlbumsDto.assetId);
|
|
||||||
} else {
|
|
||||||
albums = await this.albumRepository.getList(authUser.id, getAlbumsDto);
|
|
||||||
|
|
||||||
if (getAlbumsDto.shared) {
|
|
||||||
const publicSharingAlbums = await this.albumRepository.getPublicSharingList(authUser.id);
|
|
||||||
albums = [...albums, ...publicSharingAlbums];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
albums = _.uniqBy(albums, (album) => album.id);
|
|
||||||
|
|
||||||
return albums.map((album) => mapAlbumExcludeAssetInfo(album));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
|
async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
|
||||||
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
||||||
return mapAlbum(album);
|
return mapAlbum(album);
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { TagModule } from './api-v1/tag/tag.module';
|
||||||
import { DomainModule, SearchService } from '@app/domain';
|
import { DomainModule, SearchService } from '@app/domain';
|
||||||
import { InfraModule } from '@app/infra';
|
import { InfraModule } from '@app/infra';
|
||||||
import {
|
import {
|
||||||
|
AlbumController,
|
||||||
APIKeyController,
|
APIKeyController,
|
||||||
AuthController,
|
AuthController,
|
||||||
DeviceInfoController,
|
DeviceInfoController,
|
||||||
|
@ -35,6 +36,7 @@ import { AppCronJobs } from './app.cron-jobs';
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
AppController,
|
AppController,
|
||||||
|
AlbumController,
|
||||||
APIKeyController,
|
APIKeyController,
|
||||||
AuthController,
|
AuthController,
|
||||||
DeviceInfoController,
|
DeviceInfoController,
|
||||||
|
|
21
server/apps/immich/src/controllers/album.controller.ts
Normal file
21
server/apps/immich/src/controllers/album.controller.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { AlbumService, AuthUserDto } from '@app/domain';
|
||||||
|
import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto';
|
||||||
|
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
||||||
|
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||||
|
|
||||||
|
@ApiTags('Album')
|
||||||
|
@Controller('album')
|
||||||
|
@Authenticated()
|
||||||
|
export class AlbumController {
|
||||||
|
constructor(private service: AlbumService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getAllAlbums(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
|
@Query(new ValidationPipe({ transform: true })) query: GetAlbumsDto,
|
||||||
|
) {
|
||||||
|
return this.service.getAllAlbums(authUser, query);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from './album.controller';
|
||||||
export * from './api-key.controller';
|
export * from './api-key.controller';
|
||||||
export * from './auth.controller';
|
export * from './auth.controller';
|
||||||
export * from './device-info.controller';
|
export * from './device-info.controller';
|
||||||
|
|
|
@ -3,13 +3,22 @@ import { INestApplication } from '@nestjs/common';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { clearDb, getAuthUser, authCustom } from './test-utils';
|
import { clearDb, getAuthUser, authCustom } from './test-utils';
|
||||||
import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
|
import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
|
||||||
|
import { CreateAlbumShareLinkDto } from '../src/api-v1/album/dto/create-album-shared-link.dto';
|
||||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
||||||
import { AuthService, UserService } from '@app/domain';
|
import { AlbumResponseDto, AuthService, SharedLinkResponseDto, UserService } from '@app/domain';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { AppModule } from '../src/app.module';
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
|
async function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
|
||||||
return request(app.getHttpServer()).post('/album').send(data);
|
const res = await request(app.getHttpServer()).post('/album').send(data);
|
||||||
|
expect(res.status).toEqual(201);
|
||||||
|
return res.body as AlbumResponseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _createAlbumSharedLink(app: INestApplication, data: CreateAlbumShareLinkDto) {
|
||||||
|
const res = await request(app.getHttpServer()).post('/album/create-shared-link').send(data);
|
||||||
|
expect(res.status).toEqual(201);
|
||||||
|
return res.body as SharedLinkResponseDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Album', () => {
|
describe('Album', () => {
|
||||||
|
@ -57,30 +66,38 @@ describe('Album', () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO - Until someone figure out how to passed in a logged in user to the request.
|
describe('with empty DB', () => {
|
||||||
// describe('with empty DB', () => {
|
it('rejects invalid shared param', async () => {
|
||||||
// it('creates an album', async () => {
|
const { status } = await request(app.getHttpServer()).get('/album?shared=invalid');
|
||||||
// const data: CreateAlbumDto = {
|
expect(status).toEqual(400);
|
||||||
// albumName: 'first albbum',
|
});
|
||||||
// };
|
|
||||||
|
|
||||||
// const { status, body } = await _createAlbum(app, data);
|
it('rejects invalid assetId param', async () => {
|
||||||
|
const { status } = await request(app.getHttpServer()).get('/album?assetId=invalid');
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
// expect(status).toEqual(201);
|
// TODO - Until someone figure out how to passed in a logged in user to the request.
|
||||||
|
// it('creates an album', async () => {
|
||||||
// expect(body).toEqual(
|
// const data: CreateAlbumDto = {
|
||||||
// expect.objectContaining({
|
// albumName: 'first albbum',
|
||||||
// ownerId: authUser.id,
|
// };
|
||||||
// albumName: data.albumName,
|
// const body = await _createAlbum(app, data);
|
||||||
// }),
|
// expect(body).toEqual(
|
||||||
// );
|
// expect.objectContaining({
|
||||||
// });
|
// ownerId: authUser.id,
|
||||||
// });
|
// albumName: data.albumName,
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
// });
|
||||||
|
});
|
||||||
|
|
||||||
describe('with albums in DB', () => {
|
describe('with albums in DB', () => {
|
||||||
const userOneShared = 'userOneShared';
|
const userOneSharedUser = 'userOneSharedUser';
|
||||||
|
const userOneSharedLink = 'userOneSharedLink';
|
||||||
const userOneNotShared = 'userOneNotShared';
|
const userOneNotShared = 'userOneNotShared';
|
||||||
const userTwoShared = 'userTwoShared';
|
const userTwoSharedUser = 'userTwoSharedUser';
|
||||||
|
const userTwoSharedLink = 'userTwoSharedLink';
|
||||||
const userTwoNotShared = 'userTwoNotShared';
|
const userTwoNotShared = 'userTwoNotShared';
|
||||||
let userOne: AuthUserDto;
|
let userOne: AuthUserDto;
|
||||||
let userTwo: AuthUserDto;
|
let userTwo: AuthUserDto;
|
||||||
|
@ -104,16 +121,26 @@ describe('Album', () => {
|
||||||
|
|
||||||
// add user one albums
|
// add user one albums
|
||||||
authUser = userOne;
|
authUser = userOne;
|
||||||
await Promise.all([
|
const userOneAlbums = await Promise.all([
|
||||||
_createAlbum(app, { albumName: userOneShared, sharedWithUserIds: [userTwo.id] }),
|
_createAlbum(app, { albumName: userOneSharedUser, sharedWithUserIds: [userTwo.id] }),
|
||||||
|
_createAlbum(app, { albumName: userOneSharedLink }),
|
||||||
_createAlbum(app, { albumName: userOneNotShared }),
|
_createAlbum(app, { albumName: userOneNotShared }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// add shared link to userOneSharedLink album
|
||||||
|
await _createAlbumSharedLink(app, { albumId: userOneAlbums[1].id });
|
||||||
|
|
||||||
// add user two albums
|
// add user two albums
|
||||||
authUser = userTwo;
|
authUser = userTwo;
|
||||||
await Promise.all([
|
const userTwoAlbums = await Promise.all([
|
||||||
_createAlbum(app, { albumName: userTwoShared, sharedWithUserIds: [userOne.id] }),
|
_createAlbum(app, { albumName: userTwoSharedUser, sharedWithUserIds: [userOne.id] }),
|
||||||
|
_createAlbum(app, { albumName: userTwoSharedLink }),
|
||||||
_createAlbum(app, { albumName: userTwoNotShared }),
|
_createAlbum(app, { albumName: userTwoNotShared }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// add shared link to userTwoSharedLink album
|
||||||
|
await _createAlbumSharedLink(app, { albumId: userTwoAlbums[1].id });
|
||||||
|
|
||||||
// set user one as authed for next requests
|
// set user one as authed for next requests
|
||||||
authUser = userOne;
|
authUser = userOne;
|
||||||
});
|
});
|
||||||
|
@ -125,10 +152,11 @@ describe('Album', () => {
|
||||||
it('returns the album collection including owned and shared', async () => {
|
it('returns the album collection including owned and shared', async () => {
|
||||||
const { status, body } = await request(app.getHttpServer()).get('/album');
|
const { status, body } = await request(app.getHttpServer()).get('/album');
|
||||||
expect(status).toEqual(200);
|
expect(status).toEqual(200);
|
||||||
expect(body).toHaveLength(2);
|
expect(body).toHaveLength(3);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ ownerId: userOne.id, albumName: userOneShared, shared: true }),
|
expect.objectContaining({ ownerId: userOne.id, albumName: userOneSharedUser, shared: true }),
|
||||||
|
expect.objectContaining({ ownerId: userOne.id, albumName: userOneSharedLink, shared: true }),
|
||||||
expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }),
|
expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
@ -137,11 +165,12 @@ describe('Album', () => {
|
||||||
it('returns the album collection filtered by shared', async () => {
|
it('returns the album collection filtered by shared', async () => {
|
||||||
const { status, body } = await request(app.getHttpServer()).get('/album?shared=true');
|
const { status, body } = await request(app.getHttpServer()).get('/album?shared=true');
|
||||||
expect(status).toEqual(200);
|
expect(status).toEqual(200);
|
||||||
expect(body).toHaveLength(2);
|
expect(body).toHaveLength(3);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ ownerId: userOne.id, albumName: userOneShared, shared: true }),
|
expect.objectContaining({ ownerId: userOne.id, albumName: userOneSharedUser, shared: true }),
|
||||||
expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoShared, shared: true }),
|
expect.objectContaining({ ownerId: userOne.id, albumName: userOneSharedLink, shared: true }),
|
||||||
|
expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoSharedUser, shared: true }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -156,6 +185,33 @@ describe('Album', () => {
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO: Add asset to album and test if it returns correctly.
|
||||||
|
it('returns the album collection filtered by assetId', async () => {
|
||||||
|
const { status, body } = await request(app.getHttpServer()).get(
|
||||||
|
'/album?assetId=ecb120db-45a2-4a65-9293-51476f0d8790',
|
||||||
|
);
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(body).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Add asset to album and test if it returns correctly.
|
||||||
|
it('returns the album collection filtered by assetId and ignores shared=true', async () => {
|
||||||
|
const { status, body } = await request(app.getHttpServer()).get(
|
||||||
|
'/album?shared=true&assetId=ecb120db-45a2-4a65-9293-51476f0d8790',
|
||||||
|
);
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(body).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Add asset to album and test if it returns correctly.
|
||||||
|
it('returns the album collection filtered by assetId and ignores shared=false', async () => {
|
||||||
|
const { status, body } = await request(app.getHttpServer()).get(
|
||||||
|
'/album?shared=false&assetId=ecb120db-45a2-4a65-9293-51476f0d8790',
|
||||||
|
);
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(body).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,8 +2,19 @@ import { AlbumEntity } from '@app/infra/db/entities';
|
||||||
|
|
||||||
export const IAlbumRepository = 'IAlbumRepository';
|
export const IAlbumRepository = 'IAlbumRepository';
|
||||||
|
|
||||||
|
export interface AlbumAssetCount {
|
||||||
|
albumId: string;
|
||||||
|
assetCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IAlbumRepository {
|
export interface IAlbumRepository {
|
||||||
getByIds(ids: string[]): Promise<AlbumEntity[]>;
|
getByIds(ids: string[]): Promise<AlbumEntity[]>;
|
||||||
|
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
|
||||||
|
getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>;
|
||||||
|
getInvalidThumbnail(): Promise<string[]>;
|
||||||
|
getOwned(ownerId: string): Promise<AlbumEntity[]>;
|
||||||
|
getShared(ownerId: string): Promise<AlbumEntity[]>;
|
||||||
|
getNotShared(ownerId: string): Promise<AlbumEntity[]>;
|
||||||
deleteAll(userId: string): Promise<void>;
|
deleteAll(userId: string): Promise<void>;
|
||||||
getAll(): Promise<AlbumEntity[]>;
|
getAll(): Promise<AlbumEntity[]>;
|
||||||
save(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
|
save(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
|
||||||
|
|
114
server/libs/domain/src/album/album.service.spec.ts
Normal file
114
server/libs/domain/src/album/album.service.spec.ts
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import { albumStub, authStub, newAlbumRepositoryMock, newAssetRepositoryMock } from '../../test';
|
||||||
|
import { IAssetRepository } from '../asset';
|
||||||
|
import { IAlbumRepository } from './album.repository';
|
||||||
|
import { AlbumService } from './album.service';
|
||||||
|
|
||||||
|
describe(AlbumService.name, () => {
|
||||||
|
let sut: AlbumService;
|
||||||
|
let albumMock: jest.Mocked<IAlbumRepository>;
|
||||||
|
let assetMock: jest.Mocked<IAssetRepository>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
albumMock = newAlbumRepositoryMock();
|
||||||
|
assetMock = newAssetRepositoryMock();
|
||||||
|
|
||||||
|
sut = new AlbumService(albumMock, assetMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work', () => {
|
||||||
|
expect(sut).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get list of albums', () => {
|
||||||
|
it('gets list of albums for auth user', async () => {
|
||||||
|
albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
|
||||||
|
albumMock.getAssetCountForIds.mockResolvedValue([
|
||||||
|
{ albumId: albumStub.empty.id, assetCount: 0 },
|
||||||
|
{ albumId: albumStub.sharedWithUser.id, assetCount: 0 },
|
||||||
|
]);
|
||||||
|
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await sut.getAllAlbums(authStub.admin, {});
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].id).toEqual(albumStub.empty.id);
|
||||||
|
expect(result[1].id).toEqual(albumStub.sharedWithUser.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets list of albums that have a specific asset', async () => {
|
||||||
|
albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]);
|
||||||
|
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]);
|
||||||
|
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await sut.getAllAlbums(authStub.admin, { assetId: albumStub.oneAsset.id });
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toEqual(albumStub.oneAsset.id);
|
||||||
|
expect(albumMock.getByAssetId).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets list of albums that are shared', async () => {
|
||||||
|
albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
|
||||||
|
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.sharedWithUser.id, assetCount: 0 }]);
|
||||||
|
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await sut.getAllAlbums(authStub.admin, { shared: true });
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toEqual(albumStub.sharedWithUser.id);
|
||||||
|
expect(albumMock.getShared).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets list of albums that are NOT shared', async () => {
|
||||||
|
albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
|
||||||
|
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.empty.id, assetCount: 0 }]);
|
||||||
|
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await sut.getAllAlbums(authStub.admin, { shared: false });
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toEqual(albumStub.empty.id);
|
||||||
|
expect(albumMock.getNotShared).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts assets correctly', async () => {
|
||||||
|
albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]);
|
||||||
|
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]);
|
||||||
|
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await sut.getAllAlbums(authStub.admin, {});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].assetCount).toEqual(1);
|
||||||
|
expect(albumMock.getOwned).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the album thumbnail by listing all albums', async () => {
|
||||||
|
albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]);
|
||||||
|
albumMock.getAssetCountForIds.mockResolvedValue([
|
||||||
|
{ albumId: albumStub.oneAssetInvalidThumbnail.id, assetCount: 1 },
|
||||||
|
]);
|
||||||
|
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]);
|
||||||
|
albumMock.save.mockResolvedValue(albumStub.oneAssetValidThumbnail);
|
||||||
|
assetMock.getFirstAssetForAlbumId.mockResolvedValue(albumStub.oneAssetInvalidThumbnail.assets[0]);
|
||||||
|
|
||||||
|
const result = await sut.getAllAlbums(authStub.admin, {});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1);
|
||||||
|
expect(albumMock.save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the thumbnail for an empty album', async () => {
|
||||||
|
albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]);
|
||||||
|
albumMock.getAssetCountForIds.mockResolvedValue([
|
||||||
|
{ albumId: albumStub.emptyWithInvalidThumbnail.id, assetCount: 1 },
|
||||||
|
]);
|
||||||
|
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]);
|
||||||
|
albumMock.save.mockResolvedValue(albumStub.emptyWithValidThumbnail);
|
||||||
|
assetMock.getFirstAssetForAlbumId.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await sut.getAllAlbums(authStub.admin, {});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1);
|
||||||
|
expect(albumMock.save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
58
server/libs/domain/src/album/album.service.ts
Normal file
58
server/libs/domain/src/album/album.service.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { AlbumEntity } from '@app/infra';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { IAssetRepository } from '../asset';
|
||||||
|
import { AuthUserDto } from '../auth';
|
||||||
|
import { IAlbumRepository } from './album.repository';
|
||||||
|
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||||
|
import { AlbumResponseDto } from './response-dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AlbumService {
|
||||||
|
constructor(
|
||||||
|
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
||||||
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getAllAlbums({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||||
|
await this.updateInvalidThumbnails();
|
||||||
|
|
||||||
|
let albums: AlbumEntity[];
|
||||||
|
if (assetId) {
|
||||||
|
albums = await this.albumRepository.getByAssetId(ownerId, assetId);
|
||||||
|
} else if (shared === true) {
|
||||||
|
albums = await this.albumRepository.getShared(ownerId);
|
||||||
|
} else if (shared === false) {
|
||||||
|
albums = await this.albumRepository.getNotShared(ownerId);
|
||||||
|
} else {
|
||||||
|
albums = await this.albumRepository.getOwned(ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get asset count for each album. Then map the result to an object:
|
||||||
|
// { [albumId]: assetCount }
|
||||||
|
const albumsAssetCount = await this.albumRepository.getAssetCountForIds(albums.map((album) => album.id));
|
||||||
|
const albumsAssetCountObj = albumsAssetCount.reduce((obj: Record<string, number>, { albumId, assetCount }) => {
|
||||||
|
obj[albumId] = assetCount;
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return albums.map((album) => {
|
||||||
|
return {
|
||||||
|
...album,
|
||||||
|
sharedLinks: undefined, // Don't return shared links
|
||||||
|
shared: album.sharedLinks?.length > 0 || album.sharedUsers?.length > 0,
|
||||||
|
assetCount: albumsAssetCountObj[album.id],
|
||||||
|
} as AlbumResponseDto;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInvalidThumbnails(): Promise<number> {
|
||||||
|
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
|
||||||
|
|
||||||
|
for (const albumId of invalidAlbumIds) {
|
||||||
|
const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId);
|
||||||
|
await this.albumRepository.save({ id: albumId, albumThumbnailAsset: newThumbnail });
|
||||||
|
}
|
||||||
|
|
||||||
|
return invalidAlbumIds.length;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,13 @@
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsBoolean, IsOptional } from 'class-validator';
|
import { IsBoolean, IsOptional, IsUUID } from 'class-validator';
|
||||||
import { toBoolean } from '../../../utils/transform.util';
|
import { toBoolean } from 'apps/immich/src/utils/transform.util';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class GetAlbumsDto {
|
export class GetAlbumsDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@Transform(toBoolean)
|
@Transform(toBoolean)
|
||||||
|
@ApiProperty()
|
||||||
/**
|
/**
|
||||||
* true: only shared albums
|
* true: only shared albums
|
||||||
* false: only non-shared own albums
|
* false: only non-shared own albums
|
||||||
|
@ -18,5 +20,8 @@ export class GetAlbumsDto {
|
||||||
* Ignores the shared parameter
|
* Ignores the shared parameter
|
||||||
* undefined: get all albums
|
* undefined: get all albums
|
||||||
*/
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID(4)
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
assetId?: string;
|
assetId?: string;
|
||||||
}
|
}
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './album.repository';
|
export * from './album.repository';
|
||||||
|
export * from './album.service';
|
||||||
export * from './response-dto';
|
export * from './response-dto';
|
||||||
|
|
|
@ -18,6 +18,7 @@ export const IAssetRepository = 'IAssetRepository';
|
||||||
export interface IAssetRepository {
|
export interface IAssetRepository {
|
||||||
getByIds(ids: string[]): Promise<AssetEntity[]>;
|
getByIds(ids: string[]): Promise<AssetEntity[]>;
|
||||||
getWithout(property: WithoutProperty): Promise<AssetEntity[]>;
|
getWithout(property: WithoutProperty): Promise<AssetEntity[]>;
|
||||||
|
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
|
||||||
deleteAll(ownerId: string): Promise<void>;
|
deleteAll(ownerId: string): Promise<void>;
|
||||||
getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
|
getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
|
||||||
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
|
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
|
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
|
||||||
|
import { AlbumService } from './album';
|
||||||
import { APIKeyService } from './api-key';
|
import { APIKeyService } from './api-key';
|
||||||
import { AssetService } from './asset';
|
import { AssetService } from './asset';
|
||||||
import { AuthService } from './auth';
|
import { AuthService } from './auth';
|
||||||
|
@ -16,6 +17,7 @@ import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
|
||||||
import { UserService } from './user';
|
import { UserService } from './user';
|
||||||
|
|
||||||
const providers: Provider[] = [
|
const providers: Provider[] = [
|
||||||
|
AlbumService,
|
||||||
AssetService,
|
AssetService,
|
||||||
APIKeyService,
|
APIKeyService,
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|
|
@ -3,6 +3,12 @@ import { IAlbumRepository } from '../src';
|
||||||
export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
|
export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
|
||||||
return {
|
return {
|
||||||
getByIds: jest.fn(),
|
getByIds: jest.fn(),
|
||||||
|
getByAssetId: jest.fn(),
|
||||||
|
getAssetCountForIds: jest.fn(),
|
||||||
|
getInvalidThumbnail: jest.fn(),
|
||||||
|
getOwned: jest.fn(),
|
||||||
|
getShared: jest.fn(),
|
||||||
|
getNotShared: jest.fn(),
|
||||||
deleteAll: jest.fn(),
|
deleteAll: jest.fn(),
|
||||||
getAll: jest.fn(),
|
getAll: jest.fn(),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
|
|
|
@ -4,6 +4,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
|
||||||
return {
|
return {
|
||||||
getByIds: jest.fn(),
|
getByIds: jest.fn(),
|
||||||
getWithout: jest.fn(),
|
getWithout: jest.fn(),
|
||||||
|
getFirstAssetForAlbumId: jest.fn(),
|
||||||
getAll: jest.fn(),
|
getAll: jest.fn(),
|
||||||
deleteAll: jest.fn(),
|
deleteAll: jest.fn(),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
|
|
|
@ -219,6 +219,97 @@ export const albumStub = {
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
sharedUsers: [],
|
sharedUsers: [],
|
||||||
}),
|
}),
|
||||||
|
sharedWithUser: Object.freeze<AlbumEntity>({
|
||||||
|
id: 'album-2',
|
||||||
|
albumName: 'Empty album shared with user',
|
||||||
|
ownerId: authStub.admin.id,
|
||||||
|
owner: userEntityStub.admin,
|
||||||
|
assets: [],
|
||||||
|
albumThumbnailAsset: null,
|
||||||
|
albumThumbnailAssetId: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
sharedLinks: [],
|
||||||
|
sharedUsers: [userEntityStub.user1],
|
||||||
|
}),
|
||||||
|
sharedWithAdmin: Object.freeze<AlbumEntity>({
|
||||||
|
id: 'album-3',
|
||||||
|
albumName: 'Empty album shared with admin',
|
||||||
|
ownerId: authStub.user1.id,
|
||||||
|
owner: userEntityStub.user1,
|
||||||
|
assets: [],
|
||||||
|
albumThumbnailAsset: null,
|
||||||
|
albumThumbnailAssetId: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
sharedLinks: [],
|
||||||
|
sharedUsers: [userEntityStub.admin],
|
||||||
|
}),
|
||||||
|
oneAsset: Object.freeze<AlbumEntity>({
|
||||||
|
id: 'album-4',
|
||||||
|
albumName: 'Album with one asset',
|
||||||
|
ownerId: authStub.admin.id,
|
||||||
|
owner: userEntityStub.admin,
|
||||||
|
assets: [assetEntityStub.image],
|
||||||
|
albumThumbnailAsset: null,
|
||||||
|
albumThumbnailAssetId: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
sharedLinks: [],
|
||||||
|
sharedUsers: [],
|
||||||
|
}),
|
||||||
|
emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
|
||||||
|
id: 'album-5',
|
||||||
|
albumName: 'Empty album with invalid thumbnail',
|
||||||
|
ownerId: authStub.admin.id,
|
||||||
|
owner: userEntityStub.admin,
|
||||||
|
assets: [],
|
||||||
|
albumThumbnailAsset: assetEntityStub.image,
|
||||||
|
albumThumbnailAssetId: assetEntityStub.image.id,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
sharedLinks: [],
|
||||||
|
sharedUsers: [],
|
||||||
|
}),
|
||||||
|
emptyWithValidThumbnail: Object.freeze<AlbumEntity>({
|
||||||
|
id: 'album-5',
|
||||||
|
albumName: 'Empty album with invalid thumbnail',
|
||||||
|
ownerId: authStub.admin.id,
|
||||||
|
owner: userEntityStub.admin,
|
||||||
|
assets: [],
|
||||||
|
albumThumbnailAsset: null,
|
||||||
|
albumThumbnailAssetId: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
sharedLinks: [],
|
||||||
|
sharedUsers: [],
|
||||||
|
}),
|
||||||
|
oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({
|
||||||
|
id: 'album-6',
|
||||||
|
albumName: 'Album with one asset and invalid thumbnail',
|
||||||
|
ownerId: authStub.admin.id,
|
||||||
|
owner: userEntityStub.admin,
|
||||||
|
assets: [assetEntityStub.image],
|
||||||
|
albumThumbnailAsset: assetEntityStub.livePhotoMotionAsset,
|
||||||
|
albumThumbnailAssetId: assetEntityStub.livePhotoMotionAsset.id,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
sharedLinks: [],
|
||||||
|
sharedUsers: [],
|
||||||
|
}),
|
||||||
|
oneAssetValidThumbnail: Object.freeze<AlbumEntity>({
|
||||||
|
id: 'album-6',
|
||||||
|
albumName: 'Album with one asset and invalid thumbnail',
|
||||||
|
ownerId: authStub.admin.id,
|
||||||
|
owner: userEntityStub.admin,
|
||||||
|
assets: [assetEntityStub.image],
|
||||||
|
albumThumbnailAsset: assetEntityStub.image,
|
||||||
|
albumThumbnailAssetId: assetEntityStub.image.id,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
sharedLinks: [],
|
||||||
|
sharedUsers: [],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetInfo: ExifResponseDto = {
|
const assetInfo: ExifResponseDto = {
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
Unique,
|
Unique,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
import { AlbumEntity } from './album.entity';
|
||||||
import { ExifEntity } from './exif.entity';
|
import { ExifEntity } from './exif.entity';
|
||||||
import { SharedLinkEntity } from './shared-link.entity';
|
import { SharedLinkEntity } from './shared-link.entity';
|
||||||
import { SmartInfoEntity } from './smart-info.entity';
|
import { SmartInfoEntity } from './smart-info.entity';
|
||||||
|
@ -99,6 +100,9 @@ export class AssetEntity {
|
||||||
@ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true })
|
@ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true })
|
||||||
@JoinTable({ name: 'shared_link__asset' })
|
@JoinTable({ name: 'shared_link__asset' })
|
||||||
sharedLinks!: SharedLinkEntity[];
|
sharedLinks!: SharedLinkEntity[];
|
||||||
|
|
||||||
|
@ManyToMany(() => AlbumEntity, (album) => album.assets)
|
||||||
|
albums?: AlbumEntity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AssetType {
|
export enum AssetType {
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { IAlbumRepository } from '@app/domain';
|
import { AlbumAssetCount, IAlbumRepository } from '@app/domain';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { In, Repository } from 'typeorm';
|
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||||
|
import { dataSource } from '../config';
|
||||||
import { AlbumEntity } from '../entities';
|
import { AlbumEntity } from '../entities';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -19,6 +20,97 @@ export class AlbumRepository implements IAlbumRepository {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { ownerId, assets: { id: assetId } },
|
||||||
|
relations: { owner: true, sharedUsers: true },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]> {
|
||||||
|
// Guard against running invalid query when ids list is empty.
|
||||||
|
if (!ids.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only possible with query builder because of GROUP BY.
|
||||||
|
const countByAlbums = await this.repository
|
||||||
|
.createQueryBuilder('album')
|
||||||
|
.select('album.id')
|
||||||
|
.addSelect('COUNT(albums_assets.assetsId)', 'asset_count')
|
||||||
|
.leftJoin('albums_assets_assets', 'albums_assets', 'albums_assets.albumsId = album.id')
|
||||||
|
.where('album.id IN (:...ids)', { ids })
|
||||||
|
.groupBy('album.id')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
return countByAlbums.map<AlbumAssetCount>((albumCount) => ({
|
||||||
|
albumId: albumCount['album_id'],
|
||||||
|
assetCount: Number(albumCount['asset_count']),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the album IDs that have an invalid thumbnail, when:
|
||||||
|
* - Thumbnail references an asset outside the album
|
||||||
|
* - Empty album still has a thumbnail set
|
||||||
|
*/
|
||||||
|
async getInvalidThumbnail(): Promise<string[]> {
|
||||||
|
// Using dataSource, because there is no direct access to albums_assets_assets.
|
||||||
|
const albumHasAssets = dataSource
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('1')
|
||||||
|
.from('albums_assets_assets', 'albums_assets')
|
||||||
|
.where('"albums"."id" = "albums_assets"."albumsId"');
|
||||||
|
|
||||||
|
const albumContainsThumbnail = albumHasAssets
|
||||||
|
.clone()
|
||||||
|
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
|
||||||
|
|
||||||
|
const albums = await this.repository
|
||||||
|
.createQueryBuilder('albums')
|
||||||
|
.select('albums.id')
|
||||||
|
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
|
||||||
|
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return albums.map((album) => album.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getOwned(ownerId: string): Promise<AlbumEntity[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
relations: { sharedUsers: true, sharedLinks: true, owner: true },
|
||||||
|
where: { ownerId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get albums shared with and shared by owner.
|
||||||
|
*/
|
||||||
|
getShared(ownerId: string): Promise<AlbumEntity[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
relations: { sharedUsers: true, sharedLinks: true, owner: true },
|
||||||
|
where: [
|
||||||
|
{ sharedUsers: { id: ownerId } },
|
||||||
|
{ sharedLinks: { userId: ownerId } },
|
||||||
|
{ ownerId, sharedUsers: { id: Not(IsNull()) } },
|
||||||
|
],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get albums of owner that are _not_ shared
|
||||||
|
*/
|
||||||
|
getNotShared(ownerId: string): Promise<AlbumEntity[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
relations: { sharedUsers: true, sharedLinks: true, owner: true },
|
||||||
|
where: { ownerId, sharedUsers: { id: IsNull() }, sharedLinks: { id: IsNull() } },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async deleteAll(userId: string): Promise<void> {
|
async deleteAll(userId: string): Promise<void> {
|
||||||
await this.repository.delete({ ownerId: userId });
|
await this.repository.delete({ ownerId: userId });
|
||||||
}
|
}
|
||||||
|
|
|
@ -134,4 +134,11 @@ export class AssetRepository implements IAssetRepository {
|
||||||
where,
|
where,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { albums: { id: albumId } },
|
||||||
|
order: { fileCreatedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue