mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 04:06:26 -04:00
feat(server): harden move file (#4361)
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
parent
332a8d80f2
commit
09bf1c9175
@ -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"
|
||||||
|
@ -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)
|
||||||
|
@ -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 {
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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) {
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
@ -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';
|
||||||
|
12
server/src/domain/repositories/move.repository.ts
Normal file
12
server/src/domain/repositories/move.repository.ts
Normal 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>;
|
||||||
|
}
|
@ -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', () => {
|
||||||
|
@ -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> {
|
||||||
|
@ -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: [
|
||||||
|
@ -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> {
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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() {
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
37
server/src/infra/entities/move.entity.ts
Normal file
37
server/src/infra/entities/move.entity.ts
Normal 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;
|
@ -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 },
|
||||||
|
14
server/src/infra/migrations/1696968880063-AddMoveTable.ts
Normal file
14
server/src/infra/migrations/1696968880063-AddMoveTable.ts
Normal 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"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
@ -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';
|
||||||
|
@ -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 };
|
||||||
|
|
||||||
|
26
server/src/infra/repositories/move.repository.ts
Normal file
26
server/src/infra/repositories/move.repository.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
39
server/test/fixtures/asset.stub.ts
vendored
39
server/test/fixtures/asset.stub.ts
vendored
@ -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',
|
||||||
|
@ -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';
|
||||||
|
10
server/test/repositories/move.repository.mock.ts
Normal file
10
server/test/repositories/move.repository.mock.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user