feat(server): harden move file (#4361)

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Daniel Dietzler 2023-10-11 04:14:44 +02:00 committed by GitHub
parent 332a8d80f2
commit 09bf1c9175
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 564 additions and 190 deletions

View File

@ -66,6 +66,10 @@ ORDER BY
"users"."email"; "users"."email";
``` ```
```sql title="Failed file movements"
SELECT * FROM "move_history";
```
## Users ## Users
```sql title="List" ```sql title="List"

View File

@ -10,6 +10,8 @@ import {
newCommunicationRepositoryMock, newCommunicationRepositoryMock,
newCryptoRepositoryMock, newCryptoRepositoryMock,
newJobRepositoryMock, newJobRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
newSystemConfigRepositoryMock, newSystemConfigRepositoryMock,
} from '@test'; } from '@test';
@ -22,6 +24,8 @@ import {
ICommunicationRepository, ICommunicationRepository,
ICryptoRepository, ICryptoRepository,
IJobRepository, IJobRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
JobItem, JobItem,
@ -160,6 +164,8 @@ describe(AssetService.name, () => {
let assetMock: jest.Mocked<IAssetRepository>; let assetMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>; let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>; let communicationMock: jest.Mocked<ICommunicationRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>; let configMock: jest.Mocked<ISystemConfigRepository>;
@ -174,9 +180,21 @@ describe(AssetService.name, () => {
communicationMock = newCommunicationRepositoryMock(); communicationMock = newCommunicationRepositoryMock();
cryptoMock = newCryptoRepositoryMock(); cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, configMock, storageMock, communicationMock); sut = new AssetService(
accessMock,
assetMock,
cryptoMock,
jobMock,
configMock,
moveMock,
personMock,
storageMock,
communicationMock,
);
when(assetMock.getById) when(assetMock.getById)
.calledWith(assetStub.livePhotoStillAsset.id) .calledWith(assetStub.livePhotoStillAsset.id)

View File

@ -16,6 +16,8 @@ import {
ICommunicationRepository, ICommunicationRepository,
ICryptoRepository, ICryptoRepository,
IJobRepository, IJobRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
ImmichReadStream, ImmichReadStream,
@ -80,12 +82,14 @@ export class AssetService {
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
) { ) {
this.access = new AccessCore(accessRepository); this.access = new AccessCore(accessRepository);
this.storageCore = new StorageCore(storageRepository);
this.configCore = SystemConfigCore.create(configRepository); this.configCore = SystemConfigCore.create(configRepository);
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
} }
canUploadFile({ authUser, fieldName, file }: UploadRequest): true { canUploadFile({ authUser, fieldName, file }: UploadRequest): true {

View File

@ -14,6 +14,7 @@ import {
newAssetRepositoryMock, newAssetRepositoryMock,
newJobRepositoryMock, newJobRepositoryMock,
newMediaRepositoryMock, newMediaRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock, newPersonRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
newSystemConfigRepositoryMock, newSystemConfigRepositoryMock,
@ -25,6 +26,7 @@ import {
IAssetRepository, IAssetRepository,
IJobRepository, IJobRepository,
IMediaRepository, IMediaRepository,
IMoveRepository,
IPersonRepository, IPersonRepository,
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
@ -38,6 +40,7 @@ describe(MediaService.name, () => {
let configMock: jest.Mocked<ISystemConfigRepository>; let configMock: jest.Mocked<ISystemConfigRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let mediaMock: jest.Mocked<IMediaRepository>; let mediaMock: jest.Mocked<IMediaRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>; let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
@ -46,10 +49,11 @@ describe(MediaService.name, () => {
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
mediaMock = newMediaRepositoryMock(); mediaMock = newMediaRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock(); personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
sut = new MediaService(assetMock, personMock, jobMock, mediaMock, storageMock, configMock); sut = new MediaService(assetMock, personMock, jobMock, mediaMock, storageMock, configMock, moveMock);
}); });
it('should be defined', () => { it('should be defined', () => {

View File

@ -1,4 +1,12 @@
import { AssetEntity, AssetType, Colorspace, TranscodeHWAccel, TranscodePolicy, VideoCodec } from '@app/infra/entities'; import {
AssetEntity,
AssetPathType,
AssetType,
Colorspace,
TranscodeHWAccel,
TranscodePolicy,
VideoCodec,
} from '@app/infra/entities';
import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common'; import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common';
import { usePagination } from '../domain.util'; import { usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job'; import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
@ -7,6 +15,7 @@ import {
IAssetRepository, IAssetRepository,
IJobRepository, IJobRepository,
IMediaRepository, IMediaRepository,
IMoveRepository,
IPersonRepository, IPersonRepository,
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
@ -32,9 +41,10 @@ export class MediaService {
@Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
) { ) {
this.configCore = SystemConfigCore.create(configRepository); this.configCore = SystemConfigCore.create(configRepository);
this.storageCore = new StorageCore(this.storageRepository); this.storageCore = new StorageCore(this.storageRepository, assetRepository, moveRepository, personRepository);
} }
async handleQueueGenerateThumbnails({ force }: IBaseJob) { async handleQueueGenerateThumbnails({ force }: IBaseJob) {
@ -108,29 +118,9 @@ export class MediaService {
return false; return false;
} }
if (asset.resizePath) { await this.storageCore.moveAssetFile(asset, AssetPathType.JPEG_THUMBNAIL);
const resizePath = this.ensureThumbnailPath(asset, 'jpeg'); await this.storageCore.moveAssetFile(asset, AssetPathType.WEBP_THUMBNAIL);
if (asset.resizePath !== resizePath) { await this.storageCore.moveAssetFile(asset, AssetPathType.ENCODED_VIDEO);
await this.storageRepository.moveFile(asset.resizePath, resizePath);
await this.assetRepository.save({ id: asset.id, resizePath });
}
}
if (asset.webpPath) {
const webpPath = this.ensureThumbnailPath(asset, 'webp');
if (asset.webpPath !== webpPath) {
await this.storageRepository.moveFile(asset.webpPath, webpPath);
await this.assetRepository.save({ id: asset.id, webpPath });
}
}
if (asset.encodedVideoPath) {
const encodedVideoPath = this.ensureEncodedVideoPath(asset, 'mp4');
if (asset.encodedVideoPath !== encodedVideoPath) {
await this.storageRepository.moveFile(asset.encodedVideoPath, encodedVideoPath);
await this.assetRepository.save({ id: asset.id, encodedVideoPath });
}
}
return true; return true;
} }
@ -146,15 +136,33 @@ export class MediaService {
return true; return true;
} }
async generateThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') { private async generateThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
let path; const { thumbnail, ffmpeg } = await this.configCore.getConfig();
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
const path =
format === 'jpeg' ? this.storageCore.getLargeThumbnailPath(asset) : this.storageCore.getSmallThumbnailPath(asset);
this.storageCore.ensureFolders(path);
switch (asset.type) { switch (asset.type) {
case AssetType.IMAGE: case AssetType.IMAGE:
path = await this.generateImageThumbnail(asset, format); const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : thumbnail.colorspace;
const thumbnailOptions = { format, size, colorspace, quality: thumbnail.quality };
await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions);
break; break;
case AssetType.VIDEO: case AssetType.VIDEO:
path = await this.generateVideoThumbnail(asset, format); const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
const mainVideoStream = this.getMainStream(videoStreams);
if (!mainVideoStream) {
this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`);
return;
}
const mainAudioStream = this.getMainStream(audioStreams);
const config = { ...ffmpeg, targetResolution: size.toString() };
const options = new ThumbnailConfig(config).getOptions(mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(asset.originalPath, path, options);
break; break;
default: default:
throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`); throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`);
} }
@ -164,33 +172,6 @@ export class MediaService {
return path; return path;
} }
async generateImageThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
const { thumbnail } = await this.configCore.getConfig();
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
const path = this.ensureThumbnailPath(asset, format);
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : thumbnail.colorspace;
const thumbnailOptions = { format, size, colorspace, quality: thumbnail.quality };
await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions);
return path;
}
async generateVideoThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
const { ffmpeg, thumbnail } = await this.configCore.getConfig();
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
const mainVideoStream = this.getMainStream(videoStreams);
if (!mainVideoStream) {
this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`);
return;
}
const mainAudioStream = this.getMainStream(audioStreams);
const path = this.ensureThumbnailPath(asset, format);
const config = { ...ffmpeg, targetResolution: size.toString() };
const options = new ThumbnailConfig(config).getOptions(mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(asset.originalPath, path, options);
return path;
}
async handleGenerateWebpThumbnail({ id }: IEntityJob) { async handleGenerateWebpThumbnail({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) { if (!asset) {
@ -239,7 +220,8 @@ export class MediaService {
} }
const input = asset.originalPath; const input = asset.originalPath;
const output = this.ensureEncodedVideoPath(asset, 'mp4'); const output = this.storageCore.getEncodedVideoPath(asset);
this.storageCore.ensureFolders(output);
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input); const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
const mainVideoStream = this.getMainStream(videoStreams); const mainVideoStream = this.getMainStream(videoStreams);
@ -382,14 +364,6 @@ export class MediaService {
return handler; return handler;
} }
ensureThumbnailPath(asset: AssetEntity, extension: string): string {
return this.storageCore.ensurePath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.${extension}`);
}
ensureEncodedVideoPath(asset: AssetEntity, extension: string): string {
return this.storageCore.ensurePath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.${extension}`);
}
isSRGB(asset: AssetEntity): boolean { isSRGB(asset: AssetEntity): boolean {
const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {}; const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {};
if (colorspace || profileDescription) { if (colorspace || profileDescription) {

View File

@ -6,6 +6,8 @@ import {
newCryptoRepositoryMock, newCryptoRepositoryMock,
newJobRepositoryMock, newJobRepositoryMock,
newMetadataRepositoryMock, newMetadataRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
newSystemConfigRepositoryMock, newSystemConfigRepositoryMock,
} from '@test'; } from '@test';
@ -19,6 +21,8 @@ import {
ICryptoRepository, ICryptoRepository,
IJobRepository, IJobRepository,
IMetadataRepository, IMetadataRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
ImmichTags, ImmichTags,
@ -34,6 +38,8 @@ describe(MetadataService.name, () => {
let cryptoRepository: jest.Mocked<ICryptoRepository>; let cryptoRepository: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let metadataMock: jest.Mocked<IMetadataRepository>; let metadataMock: jest.Mocked<IMetadataRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
let sut: MetadataService; let sut: MetadataService;
@ -44,9 +50,21 @@ describe(MetadataService.name, () => {
cryptoRepository = newCryptoRepositoryMock(); cryptoRepository = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
metadataMock = newMetadataRepositoryMock(); metadataMock = newMetadataRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
sut = new MetadataService(albumMock, assetMock, cryptoRepository, jobMock, metadataMock, storageMock, configMock); sut = new MetadataService(
albumMock,
assetMock,
cryptoRepository,
jobMock,
metadataMock,
storageMock,
configMock,
moveMock,
personMock,
);
}); });
it('should be defined', () => { it('should be defined', () => {

View File

@ -12,13 +12,15 @@ import {
ICryptoRepository, ICryptoRepository,
IJobRepository, IJobRepository,
IMetadataRepository, IMetadataRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
ImmichTags, ImmichTags,
WithProperty, WithProperty,
WithoutProperty, WithoutProperty,
} from '../repositories'; } from '../repositories';
import { StorageCore, StorageFolder } from '../storage'; import { StorageCore } from '../storage';
import { FeatureFlag, SystemConfigCore } from '../system-config'; import { FeatureFlag, SystemConfigCore } from '../system-config';
interface DirectoryItem { interface DirectoryItem {
@ -73,9 +75,11 @@ export class MetadataService {
@Inject(IMetadataRepository) private repository: IMetadataRepository, @Inject(IMetadataRepository) private repository: IMetadataRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
) { ) {
this.storageCore = new StorageCore(storageRepository);
this.configCore = SystemConfigCore.create(configRepository); this.configCore = SystemConfigCore.create(configRepository);
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
this.configCore.config$.subscribe(() => this.init()); this.configCore.config$.subscribe(() => this.init());
} }
@ -296,7 +300,7 @@ export class MetadataService {
localDateTime: createdAt, localDateTime: createdAt,
checksum, checksum,
ownerId: asset.ownerId, ownerId: asset.ownerId,
originalPath: this.storageCore.ensurePath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`), originalPath: this.storageCore.getAndroidMotionPath(asset),
originalFileName: asset.originalFileName, originalFileName: asset.originalFileName,
isVisible: false, isVisible: false,
isReadOnly: false, isReadOnly: false,

View File

@ -10,6 +10,7 @@ import {
newJobRepositoryMock, newJobRepositoryMock,
newMachineLearningRepositoryMock, newMachineLearningRepositoryMock,
newMediaRepositoryMock, newMediaRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock, newPersonRepositoryMock,
newSearchRepositoryMock, newSearchRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
@ -23,6 +24,7 @@ import {
IJobRepository, IJobRepository,
IMachineLearningRepository, IMachineLearningRepository,
IMediaRepository, IMediaRepository,
IMoveRepository,
IPersonRepository, IPersonRepository,
ISearchRepository, ISearchRepository,
IStorageRepository, IStorageRepository,
@ -91,6 +93,7 @@ describe(PersonService.name, () => {
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let machineLearningMock: jest.Mocked<IMachineLearningRepository>; let machineLearningMock: jest.Mocked<IMachineLearningRepository>;
let mediaMock: jest.Mocked<IMediaRepository>; let mediaMock: jest.Mocked<IMediaRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>; let personMock: jest.Mocked<IPersonRepository>;
let searchMock: jest.Mocked<ISearchRepository>; let searchMock: jest.Mocked<ISearchRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
@ -102,6 +105,7 @@ describe(PersonService.name, () => {
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
machineLearningMock = newMachineLearningRepositoryMock(); machineLearningMock = newMachineLearningRepositoryMock();
moveMock = newMoveRepositoryMock();
mediaMock = newMediaRepositoryMock(); mediaMock = newMediaRepositoryMock();
personMock = newPersonRepositoryMock(); personMock = newPersonRepositoryMock();
searchMock = newSearchRepositoryMock(); searchMock = newSearchRepositoryMock();
@ -110,6 +114,7 @@ describe(PersonService.name, () => {
accessMock, accessMock,
assetMock, assetMock,
machineLearningMock, machineLearningMock,
moveMock,
mediaMock, mediaMock,
personMock, personMock,
searchMock, searchMock,
@ -547,19 +552,19 @@ describe(PersonService.name, () => {
it('should generate a thumbnail', async () => { it('should generate a thumbnail', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
personMock.getFacesByIds.mockResolvedValue([faceStub.middle]); personMock.getFacesByIds.mockResolvedValue([faceStub.middle]);
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(assetMock.getByIds).toHaveBeenCalledWith([faceStub.middle.assetId]); expect(assetMock.getByIds).toHaveBeenCalledWith([faceStub.middle.assetId]);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/pe/rs'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', { expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/admin-id/thumbs/path.jpg', {
left: 95, left: 95,
top: 95, top: 95,
width: 110, width: 110,
height: 110, height: 110,
}); });
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/pe/rs/person-1.jpeg', { expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', {
format: 'jpeg', format: 'jpeg',
size: 250, size: 250,
quality: 80, quality: 80,
@ -567,7 +572,7 @@ describe(PersonService.name, () => {
}); });
expect(personMock.update).toHaveBeenCalledWith({ expect(personMock.update).toHaveBeenCalledWith({
id: 'person-1', id: 'person-1',
thumbnailPath: 'upload/thumbs/user-id/pe/rs/person-1.jpeg', thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
}); });
}); });
@ -584,7 +589,7 @@ describe(PersonService.name, () => {
width: 510, width: 510,
height: 510, height: 510,
}); });
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/pe/rs/person-1.jpeg', { expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', {
format: 'jpeg', format: 'jpeg',
size: 250, size: 250,
quality: 80, quality: 80,
@ -595,17 +600,17 @@ describe(PersonService.name, () => {
it('should generate a thumbnail without overflowing', async () => { it('should generate a thumbnail without overflowing', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId });
personMock.getFacesByIds.mockResolvedValue([faceStub.end]); personMock.getFacesByIds.mockResolvedValue([faceStub.end]);
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', { expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/admin-id/thumbs/path.jpg', {
left: 297, left: 297,
top: 297, top: 297,
width: 202, width: 202,
height: 202, height: 202,
}); });
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/pe/rs/person-1.jpeg', { expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', {
format: 'jpeg', format: 'jpeg',
size: 250, size: 250,
quality: 80, quality: 80,

View File

@ -1,4 +1,5 @@
import { PersonEntity } from '@app/infra/entities'; import { PersonEntity } from '@app/infra/entities';
import { PersonPathType } from '@app/infra/entities/move.entity';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AccessCore, Permission } from '../access'; import { AccessCore, Permission } from '../access';
import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset'; import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
@ -15,6 +16,7 @@ import {
IJobRepository, IJobRepository,
IMachineLearningRepository, IMachineLearningRepository,
IMediaRepository, IMediaRepository,
IMoveRepository,
IPersonRepository, IPersonRepository,
ISearchRepository, ISearchRepository,
IStorageRepository, IStorageRepository,
@ -23,7 +25,7 @@ import {
UpdateFacesData, UpdateFacesData,
WithoutProperty, WithoutProperty,
} from '../repositories'; } from '../repositories';
import { StorageCore, StorageFolder } from '../storage'; import { StorageCore } from '../storage';
import { SystemConfigCore } from '../system-config'; import { SystemConfigCore } from '../system-config';
import { import {
MergePersonDto, MergePersonDto,
@ -46,6 +48,7 @@ export class PersonService {
@Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository, @Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IPersonRepository) private repository: IPersonRepository, @Inject(IPersonRepository) private repository: IPersonRepository,
@Inject(ISearchRepository) private searchRepository: ISearchRepository, @Inject(ISearchRepository) private searchRepository: ISearchRepository,
@ -54,8 +57,8 @@ export class PersonService {
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
) { ) {
this.access = new AccessCore(accessRepository); this.access = new AccessCore(accessRepository);
this.storageCore = new StorageCore(storageRepository);
this.configCore = SystemConfigCore.create(configRepository); this.configCore = SystemConfigCore.create(configRepository);
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, repository);
} }
async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise<PeopleResponseDto> { async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
@ -268,11 +271,7 @@ export class PersonService {
return false; return false;
} }
const path = this.storageCore.ensurePath(StorageFolder.THUMBNAILS, person.ownerId, `${id}.jpeg`); await this.storageCore.movePersonFile(person, PersonPathType.FACE);
if (person.thumbnailPath && person.thumbnailPath !== path) {
await this.storageRepository.moveFile(person.thumbnailPath, path);
await this.repository.update({ id, thumbnailPath: path });
}
return true; return true;
} }
@ -310,8 +309,8 @@ export class PersonService {
} }
this.logger.verbose(`Cropping face for person: ${personId}`); this.logger.verbose(`Cropping face for person: ${personId}`);
const thumbnailPath = this.storageCore.getPersonThumbnailPath(person);
const thumbnailPath = this.storageCore.ensurePath(StorageFolder.THUMBNAILS, asset.ownerId, `${personId}.jpeg`); this.storageCore.ensureFolders(thumbnailPath);
const halfWidth = (x2 - x1) / 2; const halfWidth = (x2 - x1) / 2;
const halfHeight = (y2 - y1) / 2; const halfHeight = (y2 - y1) / 2;

View File

@ -10,6 +10,7 @@ export * from './library.repository';
export * from './machine-learning.repository'; export * from './machine-learning.repository';
export * from './media.repository'; export * from './media.repository';
export * from './metadata.repository'; export * from './metadata.repository';
export * from './move.repository';
export * from './partner.repository'; export * from './partner.repository';
export * from './person.repository'; export * from './person.repository';
export * from './search.repository'; export * from './search.repository';

View File

@ -0,0 +1,12 @@
import { MoveEntity, PathType } from '@app/infra/entities';
export const IMoveRepository = 'IMoveRepository';
export type MoveCreate = Pick<MoveEntity, 'oldPath' | 'newPath' | 'entityId' | 'pathType'> & Partial<MoveEntity>;
export interface IMoveRepository {
create(entity: MoveCreate): Promise<MoveEntity>;
getByEntity(entityId: string, pathType: PathType): Promise<MoveEntity | null>;
update(entity: Partial<MoveEntity>): Promise<MoveEntity>;
delete(move: MoveEntity): Promise<MoveEntity>;
}

View File

@ -1,20 +1,40 @@
import { newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock } from '@test'; import {
newAssetRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
} from '@test';
import { serverVersion } from '../domain.constant'; import { serverVersion } from '../domain.constant';
import { IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories'; import {
IAssetRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
} from '../repositories';
import { ServerInfoService } from './server-info.service'; import { ServerInfoService } from './server-info.service';
describe(ServerInfoService.name, () => { describe(ServerInfoService.name, () => {
let sut: ServerInfoService; let sut: ServerInfoService;
let assetMock: jest.Mocked<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>; let configMock: jest.Mocked<ISystemConfigRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>; let userMock: jest.Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
sut = new ServerInfoService(configMock, userMock, storageMock); sut = new ServerInfoService(assetMock, configMock, moveMock, personMock, userMock, storageMock);
}); });
it('should work', () => { it('should work', () => {

View File

@ -1,7 +1,15 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { mimeTypes, serverVersion } from '../domain.constant'; import { mimeTypes, serverVersion } from '../domain.constant';
import { asHumanReadable } from '../domain.util'; import { asHumanReadable } from '../domain.util';
import { IStorageRepository, ISystemConfigRepository, IUserRepository, UserStatsQueryResponse } from '../repositories'; import {
IAssetRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
UserStatsQueryResponse,
} from '../repositories';
import { StorageCore, StorageFolder } from '../storage'; import { StorageCore, StorageFolder } from '../storage';
import { SystemConfigCore } from '../system-config'; import { SystemConfigCore } from '../system-config';
import { import {
@ -20,12 +28,15 @@ export class ServerInfoService {
private storageCore: StorageCore; private storageCore: StorageCore;
constructor( constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
) { ) {
this.configCore = SystemConfigCore.create(configRepository); this.configCore = SystemConfigCore.create(configRepository);
this.storageCore = new StorageCore(storageRepository); this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
} }
async getInfo(): Promise<ServerInfoResponseDto> { async getInfo(): Promise<ServerInfoResponseDto> {

View File

@ -1,13 +1,23 @@
import { AssetPathType } from '@app/infra/entities';
import { import {
assetStub, assetStub,
newAssetRepositoryMock, newAssetRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
newSystemConfigRepositoryMock, newSystemConfigRepositoryMock,
newUserRepositoryMock, newUserRepositoryMock,
userStub, userStub,
} from '@test'; } from '@test';
import { when } from 'jest-when'; import { when } from 'jest-when';
import { IAssetRepository, IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories'; import {
IAssetRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
} from '../repositories';
import { defaults } from '../system-config/system-config.core'; import { defaults } from '../system-config/system-config.core';
import { StorageTemplateService } from './storage-template.service'; import { StorageTemplateService } from './storage-template.service';
@ -15,6 +25,8 @@ describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService; let sut: StorageTemplateService;
let assetMock: jest.Mocked<IAssetRepository>; let assetMock: jest.Mocked<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>; let configMock: jest.Mocked<ISystemConfigRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>; let userMock: jest.Mocked<IUserRepository>;
@ -25,10 +37,12 @@ describe(StorageTemplateService.name, () => {
beforeEach(async () => { beforeEach(async () => {
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
sut = new StorageTemplateService(assetMock, configMock, defaults, storageMock, userMock); sut = new StorageTemplateService(assetMock, configMock, defaults, moveMock, personMock, storageMock, userMock);
}); });
describe('handleMigrationSingle', () => { describe('handleMigrationSingle', () => {
@ -86,6 +100,13 @@ describe(StorageTemplateService.name, () => {
}); });
assetMock.save.mockResolvedValue(assetStub.image); assetMock.save.mockResolvedValue(assetStub.image);
userMock.getList.mockResolvedValue([userStub.user1]); userMock.getList.mockResolvedValue([userStub.user1]);
moveMock.create.mockResolvedValue({
id: '123',
entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL,
oldPath: assetStub.image.originalPath,
newPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
});
when(storageMock.checkFileExists) when(storageMock.checkFileExists)
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg') .calledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg')
@ -153,6 +174,13 @@ describe(StorageTemplateService.name, () => {
}); });
assetMock.save.mockResolvedValue(assetStub.image); assetMock.save.mockResolvedValue(assetStub.image);
userMock.getList.mockResolvedValue([userStub.user1]); userMock.getList.mockResolvedValue([userStub.user1]);
moveMock.create.mockResolvedValue({
id: '123',
entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL,
oldPath: assetStub.image.originalPath,
newPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
});
await sut.handleMigration(); await sut.handleMigration();
@ -174,6 +202,13 @@ describe(StorageTemplateService.name, () => {
}); });
assetMock.save.mockResolvedValue(assetStub.image); assetMock.save.mockResolvedValue(assetStub.image);
userMock.getList.mockResolvedValue([userStub.storageLabel]); userMock.getList.mockResolvedValue([userStub.storageLabel]);
moveMock.create.mockResolvedValue({
id: '123',
entityId: assetStub.image.id,
pathType: AssetPathType.ORIGINAL,
oldPath: assetStub.image.originalPath,
newPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
});
await sut.handleMigration(); await sut.handleMigration();
@ -194,6 +229,13 @@ describe(StorageTemplateService.name, () => {
hasNextPage: false, hasNextPage: false,
}); });
storageMock.moveFile.mockRejectedValue(new Error('Read only system')); storageMock.moveFile.mockRejectedValue(new Error('Read only system'));
moveMock.create.mockResolvedValue({
id: 'move-123',
entityId: '123',
pathType: AssetPathType.ORIGINAL,
oldPath: assetStub.image.originalPath,
newPath: '',
});
userMock.getList.mockResolvedValue([userStub.user1]); userMock.getList.mockResolvedValue([userStub.user1]);
await sut.handleMigration(); await sut.handleMigration();
@ -206,27 +248,6 @@ describe(StorageTemplateService.name, () => {
expect(assetMock.save).not.toHaveBeenCalled(); expect(assetMock.save).not.toHaveBeenCalled();
}); });
it('should move the asset back if the database fails', async () => {
assetMock.getAll.mockResolvedValue({
items: [assetStub.image],
hasNextPage: false,
});
assetMock.save.mockRejectedValue('Connection Error!');
userMock.getList.mockResolvedValue([userStub.user1]);
await sut.handleMigration();
expect(assetMock.getAll).toHaveBeenCalled();
expect(assetMock.save).toHaveBeenCalledWith({
id: assetStub.image.id,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
});
expect(storageMock.moveFile.mock.calls).toEqual([
['/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg'],
['upload/library/user-id/2023/2023-02-23/asset-id.jpg', '/original/path.jpg'],
]);
});
it('should not move read-only asset', async () => { it('should not move read-only asset', async () => {
assetMock.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [ items: [

View File

@ -1,4 +1,4 @@
import { AssetEntity, AssetType, SystemConfig } from '@app/infra/entities'; import { AssetEntity, AssetPathType, AssetType, SystemConfig } from '@app/infra/entities';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import handlebar from 'handlebars'; import handlebar from 'handlebars';
import * as luxon from 'luxon'; import * as luxon from 'luxon';
@ -6,7 +6,14 @@ import path from 'node:path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { getLivePhotoMotionFilename, usePagination } from '../domain.util'; import { getLivePhotoMotionFilename, usePagination } from '../domain.util';
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job'; import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job';
import { IAssetRepository, IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories'; import {
IAssetRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
} from '../repositories';
import { StorageCore, StorageFolder } from '../storage'; import { StorageCore, StorageFolder } from '../storage';
import { import {
INITIAL_SYSTEM_CONFIG, INITIAL_SYSTEM_CONFIG,
@ -36,6 +43,8 @@ export class StorageTemplateService {
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig, @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
) { ) {
@ -43,7 +52,7 @@ export class StorageTemplateService {
this.configCore = SystemConfigCore.create(configRepository); this.configCore = SystemConfigCore.create(configRepository);
this.configCore.addValidator((config) => this.validate(config)); this.configCore.addValidator((config) => this.validate(config));
this.configCore.config$.subscribe((config) => this.onConfig(config)); this.configCore.config$.subscribe((config) => this.onConfig(config));
this.storageCore = new StorageCore(storageRepository); this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
} }
async handleMigrationSingle({ id }: IEntityJob) { async handleMigrationSingle({ id }: IEntityJob) {
@ -90,51 +99,29 @@ export class StorageTemplateService {
} }
async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
if (asset.isReadOnly || asset.isExternal) { if (asset.isReadOnly || asset.isExternal || this.storageCore.isAndroidMotionPath(asset.originalPath)) {
// External assets are not affected by storage template // External assets are not affected by storage template
// TODO: shouldn't this only apply to external assets? // TODO: shouldn't this only apply to external assets?
return; return;
} }
const destination = await this.getTemplatePath(asset, metadata); const { id, sidecarPath, originalPath } = asset;
if (asset.originalPath !== destination) { const oldPath = originalPath;
const source = asset.originalPath; const newPath = await this.getTemplatePath(asset, metadata);
let sidecarMoved = false; try {
try { await this.storageCore.moveFile({ entityId: id, pathType: AssetPathType.ORIGINAL, oldPath, newPath });
await this.storageRepository.moveFile(asset.originalPath, destination); if (sidecarPath) {
await this.storageCore.moveFile({
let sidecarDestination; entityId: id,
try { pathType: AssetPathType.SIDECAR,
if (asset.sidecarPath) { oldPath: sidecarPath,
sidecarDestination = `${destination}.xmp`; newPath: `${newPath}.xmp`,
await this.storageRepository.moveFile(asset.sidecarPath, sidecarDestination); });
sidecarMoved = true;
}
await this.assetRepository.save({ id: asset.id, originalPath: destination, sidecarPath: sidecarDestination });
asset.originalPath = destination;
asset.sidecarPath = sidecarDestination || null;
} catch (error: any) {
this.logger.warn(
`Unable to save new originalPath to database, undoing move for path ${asset.originalPath} - filename ${asset.originalFileName} - id ${asset.id}`,
error?.stack,
);
// Either sidecar move failed or the save failed. Either way, move media back
await this.storageRepository.moveFile(destination, source);
if (asset.sidecarPath && sidecarDestination && sidecarMoved) {
// If the sidecar was moved, that means the saved failed. So move both the sidecar and the
// media back into their original positions
await this.storageRepository.moveFile(sidecarDestination, asset.sidecarPath);
}
}
} catch (error: any) {
this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, source, destination });
} }
} catch (error: any) {
this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, oldPath, newPath });
} }
return asset;
} }
private async getTemplatePath(asset: AssetEntity, metadata: MoveAssetMetadata): Promise<string> { private async getTemplatePath(asset: AssetEntity, metadata: MoveAssetMetadata): Promise<string> {

View File

@ -1,6 +1,8 @@
import { join } from 'node:path'; import { AssetEntity, AssetPathType, PathType, PersonEntity, PersonPathType } from '@app/infra/entities';
import { Logger } from '@nestjs/common';
import { dirname, join } from 'node:path';
import { APP_MEDIA_LOCATION } from '../domain.constant'; import { APP_MEDIA_LOCATION } from '../domain.constant';
import { IStorageRepository } from '../repositories'; import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
export enum StorageFolder { export enum StorageFolder {
ENCODED_VIDEO = 'encoded-video', ENCODED_VIDEO = 'encoded-video',
@ -10,13 +12,26 @@ export enum StorageFolder {
THUMBNAILS = 'thumbs', THUMBNAILS = 'thumbs',
} }
export class StorageCore { export interface MoveRequest {
constructor(private repository: IStorageRepository) {} entityId: string;
pathType: PathType;
oldPath: string | null;
newPath: string;
}
getFolderLocation( type GeneratedAssetPath = AssetPathType.JPEG_THUMBNAIL | AssetPathType.WEBP_THUMBNAIL | AssetPathType.ENCODED_VIDEO;
folder: StorageFolder.ENCODED_VIDEO | StorageFolder.UPLOAD | StorageFolder.PROFILE | StorageFolder.THUMBNAILS,
userId: string, export class StorageCore {
) { private logger = new Logger(StorageCore.name);
constructor(
private repository: IStorageRepository,
private assetRepository: IAssetRepository,
private moveRepository: IMoveRepository,
private personRepository: IPersonRepository,
) {}
getFolderLocation(folder: StorageFolder, userId: string) {
return join(this.getBaseFolder(folder), userId); return join(this.getBaseFolder(folder), userId);
} }
@ -28,21 +43,119 @@ export class StorageCore {
return join(APP_MEDIA_LOCATION, folder); return join(APP_MEDIA_LOCATION, folder);
} }
ensurePath( getPersonThumbnailPath(person: PersonEntity) {
folder: StorageFolder.ENCODED_VIDEO | StorageFolder.UPLOAD | StorageFolder.PROFILE | StorageFolder.THUMBNAILS, return this.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
ownerId: string, }
fileName: string,
): string { getLargeThumbnailPath(asset: AssetEntity) {
const folderPath = join( return this.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.jpeg`);
this.getFolderLocation(folder, ownerId), }
fileName.substring(0, 2),
fileName.substring(2, 4), getSmallThumbnailPath(asset: AssetEntity) {
); return this.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.webp`);
this.repository.mkdirSync(folderPath); }
return join(folderPath, fileName);
getEncodedVideoPath(asset: AssetEntity) {
return this.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`);
}
getAndroidMotionPath(asset: AssetEntity) {
return this.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`);
}
isAndroidMotionPath(originalPath: string) {
return originalPath.startsWith(this.getBaseFolder(StorageFolder.ENCODED_VIDEO));
}
async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) {
const { id: entityId, resizePath, webpPath, encodedVideoPath } = asset;
switch (pathType) {
case AssetPathType.JPEG_THUMBNAIL:
return this.moveFile({ entityId, pathType, oldPath: resizePath, newPath: this.getLargeThumbnailPath(asset) });
case AssetPathType.WEBP_THUMBNAIL:
return this.moveFile({ entityId, pathType, oldPath: webpPath, newPath: this.getSmallThumbnailPath(asset) });
case AssetPathType.ENCODED_VIDEO:
return this.moveFile({
entityId,
pathType,
oldPath: encodedVideoPath,
newPath: this.getEncodedVideoPath(asset),
});
}
}
async movePersonFile(person: PersonEntity, pathType: PersonPathType) {
const { id: entityId, thumbnailPath } = person;
switch (pathType) {
case PersonPathType.FACE:
await this.moveFile({
entityId,
pathType,
oldPath: thumbnailPath,
newPath: this.getPersonThumbnailPath(person),
});
}
}
async moveFile(request: MoveRequest) {
const { entityId, pathType, oldPath, newPath } = request;
if (!oldPath || oldPath === newPath) {
return;
}
this.ensureFolders(newPath);
let move = await this.moveRepository.getByEntity(entityId, pathType);
if (move) {
this.logger.log(`Attempting to finish incomplete move: ${move.oldPath} => ${move.newPath}`);
const oldPathExists = await this.repository.checkFileExists(move.oldPath);
const newPathExists = await this.repository.checkFileExists(move.newPath);
const actualPath = newPathExists ? move.newPath : oldPathExists ? move.oldPath : null;
if (!actualPath) {
this.logger.warn('Unable to complete move. File does not exist at either location.');
return;
}
this.logger.log(`Found file at ${actualPath === move.oldPath ? 'old' : 'new'} location`);
move = await this.moveRepository.update({ id: move.id, oldPath: actualPath, newPath });
} else {
move = await this.moveRepository.create({ entityId, pathType, oldPath, newPath });
}
if (move.oldPath !== newPath) {
await this.repository.moveFile(move.oldPath, newPath);
}
await this.savePath(pathType, entityId, newPath);
await this.moveRepository.delete(move);
}
ensureFolders(input: string) {
this.repository.mkdirSync(dirname(input));
} }
removeEmptyDirs(folder: StorageFolder) { removeEmptyDirs(folder: StorageFolder) {
return this.repository.removeEmptyDirs(this.getBaseFolder(folder)); return this.repository.removeEmptyDirs(this.getBaseFolder(folder));
} }
private savePath(pathType: PathType, id: string, newPath: string) {
switch (pathType) {
case AssetPathType.ORIGINAL:
return this.assetRepository.save({ id, originalPath: newPath });
case AssetPathType.JPEG_THUMBNAIL:
return this.assetRepository.save({ id, resizePath: newPath });
case AssetPathType.WEBP_THUMBNAIL:
return this.assetRepository.save({ id, webpPath: newPath });
case AssetPathType.ENCODED_VIDEO:
return this.assetRepository.save({ id, encodedVideoPath: newPath });
case AssetPathType.SIDECAR:
return this.assetRepository.save({ id, sidecarPath: newPath });
case PersonPathType.FACE:
return this.personRepository.update({ id, thumbnailPath: newPath });
}
}
private getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {
return join(this.getFolderLocation(folder, ownerId), filename.substring(0, 2), filename.substring(2, 4), filename);
}
} }

View File

@ -1,14 +1,25 @@
import { newStorageRepositoryMock } from '@test'; import {
import { IStorageRepository } from '../repositories'; newAssetRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
} from '@test';
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
import { StorageService } from './storage.service'; import { StorageService } from './storage.service';
describe(StorageService.name, () => { describe(StorageService.name, () => {
let sut: StorageService; let sut: StorageService;
let assetMock: jest.Mocked<IAssetRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
beforeEach(async () => { beforeEach(async () => {
assetMock = newAssetRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
sut = new StorageService(storageMock); sut = new StorageService(assetMock, moveMock, personMock, storageMock);
}); });
it('should work', () => { it('should work', () => {

View File

@ -1,6 +1,6 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { IDeleteFilesJob } from '../job'; import { IDeleteFilesJob } from '../job';
import { IStorageRepository } from '../repositories'; import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
import { StorageCore, StorageFolder } from './storage.core'; import { StorageCore, StorageFolder } from './storage.core';
@Injectable() @Injectable()
@ -8,8 +8,13 @@ export class StorageService {
private logger = new Logger(StorageService.name); private logger = new Logger(StorageService.name);
private storageCore: StorageCore; private storageCore: StorageCore;
constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) { constructor(
this.storageCore = new StorageCore(storageRepository); @Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(IMoveRepository) private moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
} }
init() { init() {

View File

@ -11,6 +11,8 @@ import {
newCryptoRepositoryMock, newCryptoRepositoryMock,
newJobRepositoryMock, newJobRepositoryMock,
newLibraryRepositoryMock, newLibraryRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
newUserRepositoryMock, newUserRepositoryMock,
userStub, userStub,
@ -24,6 +26,8 @@ import {
ICryptoRepository, ICryptoRepository,
IJobRepository, IJobRepository,
ILibraryRepository, ILibraryRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository, IStorageRepository,
IUserRepository, IUserRepository,
} from '../repositories'; } from '../repositories';
@ -135,18 +139,32 @@ describe(UserService.name, () => {
let assetMock: jest.Mocked<IAssetRepository>; let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let libraryMock: jest.Mocked<ILibraryRepository>; let libraryMock: jest.Mocked<ILibraryRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
beforeEach(async () => { beforeEach(async () => {
cryptoRepositoryMock = newCryptoRepositoryMock();
albumMock = newAlbumRepositoryMock(); albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
cryptoRepositoryMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
libraryMock = newLibraryRepositoryMock(); libraryMock = newLibraryRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
sut = new UserService(userMock, cryptoRepositoryMock, libraryMock, albumMock, assetMock, jobMock, storageMock); sut = new UserService(
albumMock,
assetMock,
cryptoRepositoryMock,
jobMock,
libraryMock,
moveMock,
personMock,
storageMock,
userMock,
);
when(userMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser); when(userMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser);
when(userMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser); when(userMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser);

View File

@ -10,6 +10,8 @@ import {
ICryptoRepository, ICryptoRepository,
IJobRepository, IJobRepository,
ILibraryRepository, ILibraryRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository, IStorageRepository,
IUserRepository, IUserRepository,
} from '../repositories'; } from '../repositories';
@ -32,15 +34,17 @@ export class UserService {
private userCore: UserCore; private userCore: UserCore;
constructor( constructor(
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) { ) {
this.storageCore = new StorageCore(storageRepository); this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
this.userCore = new UserCore(userRepository, libraryRepository, cryptoRepository); this.userCore = new UserCore(userRepository, libraryRepository, cryptoRepository);
} }

View File

@ -5,6 +5,7 @@ import { AssetEntity } from './asset.entity';
import { AuditEntity } from './audit.entity'; import { AuditEntity } from './audit.entity';
import { ExifEntity } from './exif.entity'; import { ExifEntity } from './exif.entity';
import { LibraryEntity } from './library.entity'; import { LibraryEntity } from './library.entity';
import { MoveEntity } from './move.entity';
import { PartnerEntity } from './partner.entity'; import { PartnerEntity } from './partner.entity';
import { PersonEntity } from './person.entity'; import { PersonEntity } from './person.entity';
import { SharedLinkEntity } from './shared-link.entity'; import { SharedLinkEntity } from './shared-link.entity';
@ -21,6 +22,7 @@ export * from './asset.entity';
export * from './audit.entity'; export * from './audit.entity';
export * from './exif.entity'; export * from './exif.entity';
export * from './library.entity'; export * from './library.entity';
export * from './move.entity';
export * from './partner.entity'; export * from './partner.entity';
export * from './person.entity'; export * from './person.entity';
export * from './shared-link.entity'; export * from './shared-link.entity';
@ -37,6 +39,7 @@ export const databaseEntities = [
AssetFaceEntity, AssetFaceEntity,
AuditEntity, AuditEntity,
ExifEntity, ExifEntity,
MoveEntity,
PartnerEntity, PartnerEntity,
PersonEntity, PersonEntity,
SharedLinkEntity, SharedLinkEntity,

View File

@ -0,0 +1,37 @@
import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm';
@Entity('move_history')
// path lock (per entity)
@Unique('UQ_entityId_pathType', ['entityId', 'pathType'])
// new path lock (global)
@Unique('UQ_newPath', ['newPath'])
export class MoveEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar' })
entityId!: string;
@Column({ type: 'varchar' })
pathType!: PathType;
@Column({ type: 'varchar' })
oldPath!: string;
@Column({ type: 'varchar' })
newPath!: string;
}
export enum AssetPathType {
ORIGINAL = 'original',
JPEG_THUMBNAIL = 'jpeg_thumbnail',
WEBP_THUMBNAIL = 'webp_thumbnail',
ENCODED_VIDEO = 'encoded_video',
SIDECAR = 'sidecar',
}
export enum PersonPathType {
FACE = 'face',
}
export type PathType = AssetPathType | PersonPathType;

View File

@ -11,6 +11,7 @@ import {
IMachineLearningRepository, IMachineLearningRepository,
IMediaRepository, IMediaRepository,
IMetadataRepository, IMetadataRepository,
IMoveRepository,
IPartnerRepository, IPartnerRepository,
IPersonRepository, IPersonRepository,
ISearchRepository, ISearchRepository,
@ -44,6 +45,7 @@ import {
MachineLearningRepository, MachineLearningRepository,
MediaRepository, MediaRepository,
MetadataRepository, MetadataRepository,
MoveRepository,
PartnerRepository, PartnerRepository,
PersonRepository, PersonRepository,
SharedLinkRepository, SharedLinkRepository,
@ -67,6 +69,7 @@ const providers: Provider[] = [
{ provide: IKeyRepository, useClass: APIKeyRepository }, { provide: IKeyRepository, useClass: APIKeyRepository },
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository },
{ provide: IMetadataRepository, useClass: MetadataRepository }, { provide: IMetadataRepository, useClass: MetadataRepository },
{ provide: IMoveRepository, useClass: MoveRepository },
{ provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPartnerRepository, useClass: PartnerRepository },
{ provide: IPersonRepository, useClass: PersonRepository }, { provide: IPersonRepository, useClass: PersonRepository },
{ provide: ISearchRepository, useClass: TypesenseRepository }, { provide: ISearchRepository, useClass: TypesenseRepository },

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddMoveTable1696968880063 implements MigrationInterface {
name = 'AddMoveTable1696968880063'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "move_history" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "entityId" character varying NOT NULL, "pathType" character varying NOT NULL, "oldPath" character varying NOT NULL, "newPath" character varying NOT NULL, CONSTRAINT "UQ_newPath" UNIQUE ("newPath"), CONSTRAINT "UQ_entityId_pathType" UNIQUE ("entityId", "pathType"), CONSTRAINT "PK_af608f132233acf123f2949678d" PRIMARY KEY ("id"))`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "move_history"`);
}
}

View File

@ -6,6 +6,7 @@ import {
IStorageRepository, IStorageRepository,
mimeTypes, mimeTypes,
} from '@app/domain'; } from '@app/domain';
import { Logger } from '@nestjs/common';
import archiver from 'archiver'; import archiver from 'archiver';
import { constants, createReadStream, existsSync, mkdirSync } from 'fs'; import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
import fs, { readdir, writeFile } from 'fs/promises'; import fs, { readdir, writeFile } from 'fs/promises';
@ -17,6 +18,8 @@ import path from 'path';
const moveFile = promisify<string, string, mv.Options>(mv); const moveFile = promisify<string, string, mv.Options>(mv);
export class FilesystemProvider implements IStorageRepository { export class FilesystemProvider implements IStorageRepository {
private logger = new Logger(FilesystemProvider.name);
createZipStream(): ImmichZipStream { createZipStream(): ImmichZipStream {
const archive = archiver('zip', { store: true }); const archive = archiver('zip', { store: true });
@ -52,6 +55,8 @@ export class FilesystemProvider implements IStorageRepository {
writeFile = writeFile; writeFile = writeFile;
async moveFile(source: string, destination: string): Promise<void> { async moveFile(source: string, destination: string): Promise<void> {
this.logger.verbose(`Moving ${source} to ${destination}`);
if (await this.checkFileExists(destination)) { if (await this.checkFileExists(destination)) {
throw new Error(`Destination file already exists: ${destination}`); throw new Error(`Destination file already exists: ${destination}`);
} }

View File

@ -11,6 +11,7 @@ export * from './library.repository';
export * from './machine-learning.repository'; export * from './machine-learning.repository';
export * from './media.repository'; export * from './media.repository';
export * from './metadata.repository'; export * from './metadata.repository';
export * from './move.repository';
export * from './partner.repository'; export * from './partner.repository';
export * from './person.repository'; export * from './person.repository';
export * from './shared-link.repository'; export * from './shared-link.repository';

View File

@ -70,6 +70,8 @@ export class JobRepository implements IJobRepository {
private getJobOptions(item: JobItem): JobsOptions | null { private getJobOptions(item: JobItem): JobsOptions | null {
switch (item.name) { switch (item.name) {
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE:
return { jobId: item.data.id };
case JobName.GENERATE_PERSON_THUMBNAIL: case JobName.GENERATE_PERSON_THUMBNAIL:
return { priority: 1 }; return { priority: 1 };

View File

@ -0,0 +1,26 @@
import { IMoveRepository, MoveCreate } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MoveEntity, PathType } from '../entities';
@Injectable()
export class MoveRepository implements IMoveRepository {
constructor(@InjectRepository(MoveEntity) private repository: Repository<MoveEntity>) {}
create(entity: MoveCreate): Promise<MoveEntity> {
return this.repository.save(entity);
}
getByEntity(entityId: string, pathType: PathType): Promise<MoveEntity | null> {
return this.repository.findOne({ where: { entityId, pathType } });
}
update(entity: Partial<MoveEntity>): Promise<MoveEntity> {
return this.repository.save(entity);
}
delete(move: MoveEntity): Promise<MoveEntity> {
return this.repository.remove(move);
}
}

View File

@ -116,6 +116,45 @@ export const assetStub = {
sidecarPath: null, sidecarPath: null,
deletedAt: null, deletedAt: null,
}), }),
primaryImage: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.admin,
ownerId: 'admin-id',
deviceId: 'device-id',
originalPath: '/original/path.jpg',
resizePath: '/uploads/admin-id/thumbs/path.jpg',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: '/uploads/admin-id/webp/path.ext',
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
duration: null,
isVisible: true,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
image: Object.freeze<AssetEntity>({ image: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',

View File

@ -10,6 +10,7 @@ export * from './library.repository.mock';
export * from './machine-learning.repository.mock'; export * from './machine-learning.repository.mock';
export * from './media.repository.mock'; export * from './media.repository.mock';
export * from './metadata.repository.mock'; export * from './metadata.repository.mock';
export * from './move.repository.mock';
export * from './partner.repository.mock'; export * from './partner.repository.mock';
export * from './person.repository.mock'; export * from './person.repository.mock';
export * from './search.repository.mock'; export * from './search.repository.mock';

View File

@ -0,0 +1,10 @@
import { IMoveRepository } from '@app/domain';
export const newMoveRepositoryMock = (): jest.Mocked<IMoveRepository> => {
return {
create: jest.fn(),
getByEntity: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
};