feat(server,web): libraries (#3124)

* feat: libraries

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Jonathan Jogenfors
2023-09-20 13:16:33 +02:00
committed by GitHub
parent 816db700e1
commit acdc66413c
143 changed files with 10941 additions and 386 deletions

View File

@@ -22,7 +22,7 @@ export interface AssetOwnerCheck extends AssetCheck {
export interface IAssetRepository {
get(id: string): Promise<AssetEntity | null>;
create(
asset: Omit<AssetEntity, 'id' | 'createdAt' | 'updatedAt' | 'ownerId' | 'livePhotoVideoId'>,
asset: Omit<AssetEntity, 'id' | 'createdAt' | 'updatedAt' | 'ownerId' | 'libraryId' | 'livePhotoVideoId'>,
): Promise<AssetEntity>;
remove(asset: AssetEntity): Promise<void>;
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
@@ -146,6 +146,7 @@ export class AssetRepository implements IAssetRepository {
faces: {
person: true,
},
library: true,
},
});
}

View File

@@ -1,5 +1,5 @@
import { AuthUserDto, IJobRepository, JobName, mimeTypes, UploadFile } from '@app/domain';
import { AssetEntity, UserEntity } from '@app/infra/entities';
import { AssetEntity, LibraryEntity, UserEntity } from '@app/infra/entities';
import { parse } from 'node:path';
import { IAssetRepository } from './asset-repository';
import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto';
@@ -19,6 +19,7 @@ export class AssetCore {
): Promise<AssetEntity> {
const asset = await this.repository.create({
owner: { id: authUser.id } as UserEntity,
library: { id: dto.libraryId } as LibraryEntity,
checksum: file.checksum,
originalPath: file.originalPath,
@@ -45,6 +46,8 @@ export class AssetCore {
faces: [],
sidecarPath: sidecarPath || null,
isReadOnly: dto.isReadOnly ?? false,
isExternal: dto.isExternal ?? false,
isOffline: dto.isOffline ?? false,
});
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });

View File

@@ -1,14 +1,16 @@
import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { ICryptoRepository, IJobRepository, ILibraryRepository, IStorageRepository, JobName } from '@app/domain';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import {
IAccessRepositoryMock,
assetStub,
authStub,
fileStub,
libraryStub,
newAccessRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock,
newLibraryRepositoryMock,
newStorageRepositoryMock,
} from '@test';
import { when } from 'jest-when';
@@ -27,6 +29,7 @@ const _getCreateAssetDto = (): CreateAssetDto => {
createAssetDto.isFavorite = false;
createAssetDto.isArchived = false;
createAssetDto.duration = '0:00:00.000000';
createAssetDto.libraryId = 'libraryId';
return createAssetDto;
};
@@ -89,6 +92,7 @@ describe('AssetService', () => {
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let libraryMock: jest.Mocked<ILibraryRepository>;
beforeEach(() => {
assetRepositoryMock = {
@@ -111,8 +115,9 @@ describe('AssetService', () => {
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock();
libraryMock = newLibraryRepositoryMock();
sut = new AssetService(accessMock, assetRepositoryMock, a, cryptoMock, jobMock, storageMock);
sut = new AssetService(accessMock, assetRepositoryMock, a, cryptoMock, jobMock, libraryMock, storageMock);
when(assetRepositoryMock.get)
.calledWith(assetStub.livePhotoStillAsset.id)
@@ -149,7 +154,7 @@ describe('AssetService', () => {
};
const dto = _getCreateAssetDto();
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
assetRepositoryMock.create.mockRejectedValue(error);
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]);
@@ -166,7 +171,7 @@ describe('AssetService', () => {
it('should handle a live photo', async () => {
const dto = _getCreateAssetDto();
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
@@ -217,7 +222,10 @@ describe('AssetService', () => {
});
it('should return failed status a delete fails', async () => {
assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity);
assetRepositoryMock.get.mockResolvedValue({
id: 'asset1',
library: libraryStub.uploadLibrary1,
} as AssetEntity);
assetRepositoryMock.remove.mockRejectedValue('delete failed');
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
@@ -261,6 +269,7 @@ describe('AssetService', () => {
originalPath: 'original-path-1',
resizePath: 'resize-path-1',
webpPath: 'web-path-1',
library: libraryStub.uploadLibrary1,
};
const asset2 = {
@@ -269,6 +278,17 @@ describe('AssetService', () => {
resizePath: 'resize-path-2',
webpPath: 'web-path-2',
encodedVideoPath: 'encoded-video-path-2',
library: libraryStub.uploadLibrary1,
};
// Can't be deleted since it's external
const asset3 = {
id: 'asset3',
originalPath: 'original-path-3',
resizePath: 'resize-path-3',
webpPath: 'web-path-3',
encodedVideoPath: 'encoded-video-path-2',
library: libraryStub.externalLibrary1,
};
when(assetRepositoryMock.get)
@@ -277,12 +297,16 @@ describe('AssetService', () => {
when(assetRepositoryMock.get)
.calledWith(asset2.id)
.mockResolvedValue(asset2 as AssetEntity);
when(assetRepositoryMock.get)
.calledWith(asset3.id)
.mockResolvedValue(asset3 as AssetEntity);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2', 'asset3'] })).resolves.toEqual([
{ id: 'asset1', status: 'SUCCESS' },
{ id: 'asset2', status: 'SUCCESS' },
{ id: 'asset3', status: 'FAILED' },
]);
expect(jobMock.queue.mock.calls).toEqual([
@@ -349,6 +373,7 @@ describe('AssetService', () => {
..._getCreateAssetDto(),
assetPath: '/data/user1/fake_path/asset_1.jpeg',
isReadOnly: true,
libraryId: 'library-id',
}),
).resolves.toEqual({ duplicate: false, id: 'asset-id' });
@@ -357,7 +382,7 @@ describe('AssetService', () => {
it('should handle a duplicate if originalPath already exists', async () => {
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
assetRepositoryMock.create.mockRejectedValue(error);
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([assetStub.image]);
@@ -369,6 +394,7 @@ describe('AssetService', () => {
..._getCreateAssetDto(),
assetPath: '/data/user1/fake_path/asset_1.jpeg',
isReadOnly: true,
libraryId: 'library-id',
}),
).resolves.toEqual({ duplicate: true, id: 'asset-id' });

View File

@@ -6,6 +6,7 @@ import {
IAccessRepository,
ICryptoRepository,
IJobRepository,
ILibraryRepository,
IStorageRepository,
JobName,
mapAsset,
@@ -14,7 +15,7 @@ import {
Permission,
UploadFile,
} from '@app/domain';
import { AssetEntity, AssetType } from '@app/infra/entities';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
import {
BadRequestException,
Inject,
@@ -65,6 +66,7 @@ export class AssetService {
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) private libraryRepository: ILibraryRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.assetCore = new AssetCore(_assetRepository, jobRepository);
@@ -93,6 +95,16 @@ export class AssetService {
livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
}
if (!dto.libraryId) {
// No library given, fall back to default upload library
const defaultUploadLibrary = await this.libraryRepository.getDefaultUploadLibrary(authUser.id);
if (!defaultUploadLibrary) {
throw new InternalServerErrorException('Cannot find default upload library for user ' + authUser.id);
}
dto.libraryId = defaultUploadLibrary.id;
}
const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile?.originalPath);
return { id: asset.id, duplicate: false };
@@ -104,7 +116,7 @@ export class AssetService {
});
// handle duplicates with a success response
if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') {
if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) {
const checksums = [file.checksum, livePhotoFile?.checksum].filter((checksum): checksum is Buffer => !!checksum);
const [duplicate] = await this._assetRepository.getAssetsByChecksums(authUser.id, checksums);
return { id: duplicate.id, duplicate: true };
@@ -156,22 +168,11 @@ export class AssetService {
return { id: asset.id, duplicate: false };
} catch (error: QueryFailedError | Error | any) {
// handle duplicates with a success response
if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') {
if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) {
const [duplicate] = await this._assetRepository.getAssetsByChecksums(authUser.id, [assetFile.checksum]);
return { id: duplicate.id, duplicate: true };
}
if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_4ed4f8052685ff5b1e7ca1058ba') {
const duplicate = await this._assetRepository.getByOriginalPath(dto.assetPath);
if (duplicate) {
if (duplicate.ownerId === authUser.id) {
return { id: duplicate.id, duplicate: true };
}
throw new BadRequestException('Path in use by another user');
}
}
this.logger.error(`Error importing file ${error}`, error?.stack);
throw new BadRequestException(`Error importing file`, `${error}`);
}
@@ -183,7 +184,7 @@ export class AssetService {
public async getAllAssets(authUser: AuthUserDto, dto: AssetSearchDto): Promise<AssetResponseDto[]> {
const userId = dto.userId || authUser.id;
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
await this.access.requirePermission(authUser, Permission.TIMELINE_READ, userId);
const assets = await this._assetRepository.getAllByUserId(userId, dto);
return assets.map((asset) => mapAsset(asset));
}
@@ -258,7 +259,8 @@ export class AssetService {
}
const asset = await this._assetRepository.get(id);
if (!asset) {
if (!asset || !asset.library || asset.library.type === LibraryType.EXTERNAL) {
// We don't allow deletions assets belong to an external library
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
continue;
}
@@ -291,7 +293,8 @@ export class AssetService {
if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
ids.push(asset.livePhotoVideoId);
}
} catch {
} catch (error) {
this.logger.error(`Error deleting asset ${id}`, error);
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
}
}

View File

@@ -1,4 +1,4 @@
import { Optional, toBoolean, UploadFieldName } from '@app/domain';
import { Optional, toBoolean, UploadFieldName, ValidateUUID } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsDate, IsNotEmpty, IsString } from 'class-validator';
@@ -39,13 +39,24 @@ export class CreateAssetBase {
@Optional()
@IsString()
duration?: string;
@Optional()
@IsBoolean()
isExternal?: boolean;
@Optional()
@IsBoolean()
isOffline?: boolean;
}
export class CreateAssetDto extends CreateAssetBase {
@Optional()
@IsBoolean()
@Transform(toBoolean)
isReadOnly?: boolean = false;
isReadOnly?: boolean;
@ValidateUUID({ optional: true })
libraryId?: string;
// The properties below are added to correctly generate the API docs
// and client SDKs. Validation should be handled in the controller.
@@ -65,6 +76,9 @@ export class ImportAssetDto extends CreateAssetBase {
@Transform(toBoolean)
isReadOnly?: boolean = true;
@ValidateUUID()
libraryId?: string;
@IsString()
@IsNotEmpty()
assetPath!: string;

View File

@@ -19,6 +19,7 @@ import {
AuditController,
AuthController,
JobController,
LibraryController,
OAuthController,
PartnerController,
PersonController,
@@ -46,6 +47,7 @@ import {
AuditController,
AuthController,
JobController,
LibraryController,
OAuthController,
PartnerController,
SearchController,

View File

@@ -5,6 +5,7 @@ export * from './asset.controller';
export * from './audit.controller';
export * from './auth.controller';
export * from './job.controller';
export * from './library.controller';
export * from './oauth.controller';
export * from './partner.controller';
export * from './person.controller';

View File

@@ -0,0 +1,69 @@
import {
AuthUserDto,
CreateLibraryDto as CreateDto,
LibraryService,
LibraryStatsResponseDto,
LibraryResponseDto as ResponseDto,
ScanLibraryDto,
UpdateLibraryDto as UpdateDto,
} from '@app/domain';
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthUser, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Library')
@Controller('library')
@Authenticated()
@UseValidation()
export class LibraryController {
constructor(private service: LibraryService) {}
@Get()
getAllForUser(@AuthUser() authUser: AuthUserDto): Promise<ResponseDto[]> {
return this.service.getAllForUser(authUser);
}
@Post()
createLibrary(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto): Promise<ResponseDto> {
return this.service.create(authUser, dto);
}
@Put(':id')
updateLibrary(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UpdateDto,
): Promise<ResponseDto> {
return this.service.update(authUser, id, dto);
}
@Get(':id')
getLibraryInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<ResponseDto> {
return this.service.get(authUser, id);
}
@Delete(':id')
deleteLibrary(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(authUser, id);
}
@Get(':id/statistics')
getLibraryStatistics(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
): Promise<LibraryStatsResponseDto> {
return this.service.getStatistics(authUser, id);
}
@Post(':id/scan')
scanLibrary(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) {
return this.service.queueScan(authUser, id, dto);
}
@Post(':id/removeOffline')
removeOfflineFiles(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.queueRemoveOffline(authUser, id);
}
}