mirror of
https://github.com/immich-app/immich.git
synced 2025-12-24 01:11:32 +03:00
feat: API operation replaceAsset, POST /api/asset/:id/file (#9684)
* impl and unit tests for replaceAsset * Remove it.only * Typo in generated spec +regen * Remove unused dtos * Dto removal fallout/bugfix * fix - missed a line * sql:generate * Review comments * Unused imports * chore: clean up --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
56
server/src/controllers/asset-media.controller.ts
Normal file
56
server/src/controllers/asset-media.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Param,
|
||||
ParseFilePipe,
|
||||
Put,
|
||||
Res,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { EndpointLifecycle } from 'src/decorators';
|
||||
import { AssetMediaResponseDto, AssetMediaStatusEnum } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor, Route, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags('Asset')
|
||||
@Controller(Route.ASSET)
|
||||
export class AssetMediaController {
|
||||
constructor(
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
private service: AssetMediaService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Replace the asset with new file, without changing its id
|
||||
*/
|
||||
@Put(':id/file')
|
||||
@UseInterceptors(FileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@Authenticated({ sharedLink: true })
|
||||
@EndpointLifecycle({ addedAt: 'v1.106.0' })
|
||||
async replaceAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
|
||||
files: UploadFiles,
|
||||
@Body() dto: AssetMediaReplaceDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
const { file } = getFiles(files);
|
||||
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
|
||||
if (responseDto.status === AssetMediaStatusEnum.DUPLICATE) {
|
||||
res.status(HttpStatus.OK);
|
||||
}
|
||||
return responseDto;
|
||||
}
|
||||
}
|
||||
@@ -34,17 +34,11 @@ import { AuthDto, ImmichHeader } from 'src/dtos/auth.dto';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from 'src/middleware/file-upload.interceptor';
|
||||
import { FileUploadInterceptor, Route, UploadFiles, mapToUploadFile } from 'src/middleware/file-upload.interceptor';
|
||||
import { AssetServiceV1 } from 'src/services/asset-v1.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||
|
||||
interface UploadFiles {
|
||||
assetData: ImmichFile[];
|
||||
livePhotoData?: ImmichFile[];
|
||||
sidecarData: ImmichFile[];
|
||||
}
|
||||
|
||||
@ApiTags('Asset')
|
||||
@Controller(Route.ASSET)
|
||||
export class AssetControllerV1 {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ActivityController } from 'src/controllers/activity.controller';
|
||||
import { AlbumController } from 'src/controllers/album.controller';
|
||||
import { APIKeyController } from 'src/controllers/api-key.controller';
|
||||
import { AppController } from 'src/controllers/app.controller';
|
||||
import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||
import { AssetControllerV1 } from 'src/controllers/asset-v1.controller';
|
||||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
import { AuditController } from 'src/controllers/audit.controller';
|
||||
@@ -35,6 +36,7 @@ export const controllers = [
|
||||
AppController,
|
||||
AssetController,
|
||||
AssetControllerV1,
|
||||
AssetMediaController,
|
||||
AuditController,
|
||||
AuthController,
|
||||
DownloadController,
|
||||
|
||||
11
server/src/dtos/asset-media-response.dto.ts
Normal file
11
server/src/dtos/asset-media-response.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export enum AssetMediaStatusEnum {
|
||||
REPLACED = 'replaced',
|
||||
DUPLICATE = 'duplicate',
|
||||
}
|
||||
export class AssetMediaResponseDto {
|
||||
@ApiProperty({ enum: AssetMediaStatusEnum, enumName: 'AssetMediaStatus' })
|
||||
status!: AssetMediaStatusEnum;
|
||||
id!: string;
|
||||
}
|
||||
35
server/src/dtos/asset-media.dto.ts
Normal file
35
server/src/dtos/asset-media.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { Optional, ValidateDate } from 'src/validation';
|
||||
|
||||
export enum UploadFieldName {
|
||||
ASSET_DATA = 'assetData',
|
||||
LIVE_PHOTO_DATA = 'livePhotoData',
|
||||
SIDECAR_DATA = 'sidecarData',
|
||||
PROFILE_DATA = 'file',
|
||||
}
|
||||
|
||||
export class AssetMediaReplaceDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceAssetId!: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceId!: string;
|
||||
|
||||
@ValidateDate()
|
||||
fileCreatedAt!: Date;
|
||||
|
||||
@ValidateDate()
|
||||
fileModifiedAt!: Date;
|
||||
|
||||
@Optional()
|
||||
@IsString()
|
||||
duration?: string;
|
||||
|
||||
// The properties below are added to correctly generate the API docs
|
||||
// and client SDKs. Validation should be handled in the controller.
|
||||
@ApiProperty({ type: 'string', format: 'binary' })
|
||||
[UploadFieldName.ASSET_DATA]!: any;
|
||||
}
|
||||
@@ -111,7 +111,10 @@ export type AssetWithoutRelations = Omit<
|
||||
| 'tags'
|
||||
>;
|
||||
|
||||
export type AssetUpdateOptions = Pick<AssetWithoutRelations, 'id'> & Partial<AssetWithoutRelations>;
|
||||
type AssetUpdateWithoutRelations = Pick<AssetWithoutRelations, 'id'> & Partial<AssetWithoutRelations>;
|
||||
type AssetUpdateWithLivePhotoRelation = Pick<AssetWithoutRelations, 'id'> & Pick<AssetEntity, 'livePhotoVideo'>;
|
||||
|
||||
export type AssetUpdateOptions = AssetUpdateWithoutRelations | AssetUpdateWithLivePhotoRelation;
|
||||
|
||||
export type AssetUpdateAllOptions = Omit<Partial<AssetWithoutRelations>, 'id'>;
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export interface IBaseJob {
|
||||
|
||||
export interface IEntityJob extends IBaseJob {
|
||||
id: string;
|
||||
source?: 'upload' | 'sidecar-write';
|
||||
source?: 'upload' | 'sidecar-write' | 'copy';
|
||||
}
|
||||
|
||||
export interface ILibraryFileJob extends IEntityJob {
|
||||
|
||||
@@ -6,10 +6,30 @@ import { NextFunction, RequestHandler } from 'express';
|
||||
import multer, { StorageEngine, diskStorage } from 'multer';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { Observable } from 'rxjs';
|
||||
import { UploadFieldName } from 'src/dtos/asset.dto';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { AssetService, UploadFile } from 'src/services/asset.service';
|
||||
import { UploadFile } from 'src/services/asset-media.service';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
|
||||
export interface UploadFiles {
|
||||
assetData: ImmichFile[];
|
||||
livePhotoData?: ImmichFile[];
|
||||
sidecarData: ImmichFile[];
|
||||
}
|
||||
|
||||
export function getFile(files: UploadFiles, property: 'assetData' | 'livePhotoData' | 'sidecarData') {
|
||||
const file = files[property]?.[0];
|
||||
return file ? mapToUploadFile(file) : file;
|
||||
}
|
||||
|
||||
export function getFiles(files: UploadFiles) {
|
||||
return {
|
||||
file: getFile(files, 'assetData') as UploadFile,
|
||||
livePhotoFile: getFile(files, 'livePhotoData'),
|
||||
sidecarFile: getFile(files, 'sidecarData'),
|
||||
};
|
||||
}
|
||||
|
||||
export enum Route {
|
||||
ASSET = 'asset',
|
||||
|
||||
280
server/src/services/asset-media.service.spec.ts
Normal file
280
server/src/services/asset-media.service.spec.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { Stats } from 'node:fs';
|
||||
import { AssetMediaStatusEnum } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaReplaceDto } from 'src/dtos/asset-media.dto';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AssetMediaService, UploadFile } from 'src/services/asset-media.service';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { fileStub } from 'test/fixtures/file.stub';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
const _getUpdateAssetDto = (): AssetMediaReplaceDto => {
|
||||
return Object.assign(new AssetMediaReplaceDto(), {
|
||||
deviceAssetId: 'deviceAssetId',
|
||||
deviceId: 'deviceId',
|
||||
fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
updatedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
});
|
||||
};
|
||||
|
||||
const _getAsset_1 = () => {
|
||||
const asset_1 = new AssetEntity();
|
||||
|
||||
asset_1.id = 'id_1';
|
||||
asset_1.ownerId = 'user_id_1';
|
||||
asset_1.deviceAssetId = 'device_asset_id_1';
|
||||
asset_1.deviceId = 'device_id_1';
|
||||
asset_1.type = AssetType.VIDEO;
|
||||
asset_1.originalPath = 'fake_path/asset_1.jpeg';
|
||||
asset_1.previewPath = '';
|
||||
asset_1.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.isFavorite = false;
|
||||
asset_1.isArchived = false;
|
||||
asset_1.thumbnailPath = '';
|
||||
asset_1.encodedVideoPath = '';
|
||||
asset_1.duration = '0:00:00.000000';
|
||||
asset_1.exifInfo = new ExifEntity();
|
||||
asset_1.exifInfo.latitude = 49.533_547;
|
||||
asset_1.exifInfo.longitude = 10.703_075;
|
||||
asset_1.livePhotoVideoId = null;
|
||||
asset_1.sidecarPath = null;
|
||||
return asset_1;
|
||||
};
|
||||
const _getExistingAsset = () => {
|
||||
return {
|
||||
..._getAsset_1(),
|
||||
duration: null,
|
||||
type: AssetType.IMAGE,
|
||||
checksum: Buffer.from('_getExistingAsset', 'utf8'),
|
||||
libraryId: 'libraryId',
|
||||
} as AssetEntity;
|
||||
};
|
||||
const _getExistingAssetWithSideCar = () => {
|
||||
return {
|
||||
..._getExistingAsset(),
|
||||
sidecarPath: 'sidecar-path',
|
||||
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
|
||||
} as AssetEntity;
|
||||
};
|
||||
const _getCopiedAsset = () => {
|
||||
return {
|
||||
id: 'copied-asset',
|
||||
originalPath: 'copied-path',
|
||||
} as AssetEntity;
|
||||
};
|
||||
|
||||
describe('AssetMediaService', () => {
|
||||
let sut: AssetMediaService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
accessMock = newAccessRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
|
||||
sut = new AssetMediaService(accessMock, assetMock, jobMock, storageMock, userMock, eventMock, loggerMock);
|
||||
});
|
||||
|
||||
describe('replaceAsset', () => {
|
||||
const expectAssetUpdate = (
|
||||
existingAsset: AssetEntity,
|
||||
uploadFile: UploadFile,
|
||||
dto: AssetMediaReplaceDto,
|
||||
livePhotoVideo?: AssetEntity,
|
||||
sidecarPath?: UploadFile,
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
) => {
|
||||
expect(assetMock.update).toHaveBeenCalledWith({
|
||||
id: existingAsset.id,
|
||||
checksum: uploadFile.checksum,
|
||||
originalFileName: uploadFile.originalName,
|
||||
originalPath: uploadFile.originalPath,
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
type: mimeTypes.assetType(uploadFile.originalPath),
|
||||
duration: dto.duration || null,
|
||||
livePhotoVideo: livePhotoVideo ? { id: livePhotoVideo?.id } : null,
|
||||
sidecarPath: sidecarPath?.originalPath || null,
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
const expectAssetCreateCopy = (existingAsset: AssetEntity) => {
|
||||
expect(assetMock.create).toHaveBeenCalledWith({
|
||||
ownerId: existingAsset.ownerId,
|
||||
originalPath: existingAsset.originalPath,
|
||||
originalFileName: existingAsset.originalFileName,
|
||||
libraryId: existingAsset.libraryId,
|
||||
deviceAssetId: existingAsset.deviceAssetId,
|
||||
deviceId: existingAsset.deviceId,
|
||||
type: existingAsset.type,
|
||||
checksum: existingAsset.checksum,
|
||||
fileCreatedAt: existingAsset.fileCreatedAt,
|
||||
localDateTime: existingAsset.localDateTime,
|
||||
fileModifiedAt: existingAsset.fileModifiedAt,
|
||||
livePhotoVideoId: existingAsset.livePhotoVideoId || null,
|
||||
sidecarPath: existingAsset.sidecarPath || null,
|
||||
});
|
||||
};
|
||||
|
||||
it('should error when update photo does not exist', async () => {
|
||||
const dto = _getUpdateAssetDto();
|
||||
assetMock.getById.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, 'id', dto, fileStub.photo)).rejects.toThrow(
|
||||
'Not found or no asset.update access',
|
||||
);
|
||||
|
||||
expect(assetMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should update a photo with no sidecar to photo with no sidecar', async () => {
|
||||
const existingAsset = _getExistingAsset();
|
||||
const updatedFile = fileStub.photo;
|
||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
||||
const dto = _getUpdateAssetDto();
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(updatedAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
// this is the original file size
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
assetMock.create.mockResolvedValue(_getCopiedAsset());
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatusEnum.REPLACED,
|
||||
id: _getCopiedAsset().id,
|
||||
});
|
||||
|
||||
expectAssetUpdate(existingAsset, updatedFile, dto);
|
||||
expectAssetCreateCopy(existingAsset);
|
||||
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]);
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(dto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
it('should update a photo with sidecar to photo with sidecar', async () => {
|
||||
const existingAsset = _getExistingAssetWithSideCar();
|
||||
|
||||
const updatedFile = fileStub.photo;
|
||||
const sidecarFile = fileStub.photoSidecar;
|
||||
const dto = _getUpdateAssetDto();
|
||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(updatedAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
// this is the original file size
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
assetMock.create.mockResolvedValue(_getCopiedAsset());
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile, sidecarFile)).resolves.toEqual({
|
||||
status: AssetMediaStatusEnum.REPLACED,
|
||||
id: _getCopiedAsset().id,
|
||||
});
|
||||
|
||||
expectAssetUpdate(existingAsset, updatedFile, dto, undefined, sidecarFile);
|
||||
expectAssetCreateCopy(existingAsset);
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]);
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(dto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
it('should update a photo with a sidecar to photo with no sidecar', async () => {
|
||||
const existingAsset = _getExistingAssetWithSideCar();
|
||||
const updatedFile = fileStub.photo;
|
||||
|
||||
const dto = _getUpdateAssetDto();
|
||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(updatedAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
// this is the original file size
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the copy call
|
||||
assetMock.create.mockResolvedValue(_getCopiedAsset());
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatusEnum.REPLACED,
|
||||
id: _getCopiedAsset().id,
|
||||
});
|
||||
|
||||
expectAssetUpdate(existingAsset, updatedFile, dto);
|
||||
expectAssetCreateCopy(existingAsset);
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]);
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(dto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
it('should handle a photo with sidecar to duplicate photo ', async () => {
|
||||
const existingAsset = _getExistingAssetWithSideCar();
|
||||
const updatedFile = fileStub.photo;
|
||||
const dto = _getUpdateAssetDto();
|
||||
const error = new QueryFailedError('', [], new Error('unique key violation'));
|
||||
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
assetMock.update.mockRejectedValue(error);
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue(existingAsset.id);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
// this is the original file size
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
assetMock.create.mockResolvedValue(_getCopiedAsset());
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatusEnum.DUPLICATE,
|
||||
id: existingAsset.id,
|
||||
});
|
||||
|
||||
expectAssetUpdate(existingAsset, updatedFile, dto);
|
||||
expect(assetMock.create).not.toHaveBeenCalled();
|
||||
expect(assetMock.softDeleteAll).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILES,
|
||||
data: { files: [updatedFile.originalPath, undefined] },
|
||||
});
|
||||
expect(userMock.updateUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
177
server/src/services/asset-media.service.ts
Normal file
177
server/src/services/asset-media.service.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||
import { AssetMediaResponseDto, AssetMediaStatusEnum } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
|
||||
export interface UploadRequest {
|
||||
auth: AuthDto | null;
|
||||
fieldName: UploadFieldName;
|
||||
file: UploadFile;
|
||||
}
|
||||
|
||||
export interface UploadFile {
|
||||
uuid: string;
|
||||
checksum: Buffer;
|
||||
originalPath: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetMediaService {
|
||||
private access: AccessCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.logger.setContext(AssetMediaService.name);
|
||||
this.access = AccessCore.create(accessRepository);
|
||||
}
|
||||
|
||||
public async replaceAsset(
|
||||
auth: AuthDto,
|
||||
id: string,
|
||||
dto: AssetMediaReplaceDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
try {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id);
|
||||
const existingAssetEntity = (await this.assetRepository.getById(id)) as AssetEntity;
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
|
||||
await this.replaceFileData(existingAssetEntity.id, dto, file, sidecarFile?.originalPath);
|
||||
|
||||
// Next, create a backup copy of the existing record. The db record has already been updated above,
|
||||
// but the local variable holds the original file data paths.
|
||||
const copiedPhoto = await this.createCopy(existingAssetEntity);
|
||||
// and immediate trash it
|
||||
await this.assetRepository.softDeleteAll([copiedPhoto.id]);
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]);
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
|
||||
return { status: AssetMediaStatusEnum.REPLACED, id: copiedPhoto.id };
|
||||
} catch (error: any) {
|
||||
return await this.handleUploadError(error, auth, file, sidecarFile);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleUploadError(
|
||||
error: any,
|
||||
auth: AuthDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
// clean up files
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.DELETE_FILES,
|
||||
data: { files: [file.originalPath, sidecarFile?.originalPath] },
|
||||
});
|
||||
|
||||
// handle duplicates with a success response
|
||||
if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
return { status: AssetMediaStatusEnum.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the specified assetId to the specified photo data file properties: checksum, path,
|
||||
* timestamps, deviceIds, and sidecar. Derived properties like: faces, smart search info, etc
|
||||
* are UNTOUCHED. The photo data files modification times on the filesysytem are updated to
|
||||
* the specified timestamps. The exif db record is upserted, and then A METADATA_EXTRACTION
|
||||
* job is queued to update these derived properties.
|
||||
*/
|
||||
private async replaceFileData(
|
||||
assetId: string,
|
||||
dto: AssetMediaReplaceDto,
|
||||
file: UploadFile,
|
||||
sidecarPath?: string,
|
||||
): Promise<void> {
|
||||
await this.assetRepository.update({
|
||||
id: assetId,
|
||||
|
||||
checksum: file.checksum,
|
||||
originalPath: file.originalPath,
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
originalFileName: file.originalName,
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
duration: dto.duration || null,
|
||||
|
||||
livePhotoVideo: null,
|
||||
sidecarPath: sidecarPath || null,
|
||||
});
|
||||
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size });
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.METADATA_EXTRACTION,
|
||||
data: { id: assetId, source: 'upload' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 'shallow' copy of the specified asset record creating a new asset record in the database.
|
||||
* Uses only vital properties excluding things like: stacks, faces, smart search info, etc,
|
||||
* and then queues a METADATA_EXTRACTION job.
|
||||
*/
|
||||
private async createCopy(asset: AssetEntity): Promise<AssetEntity> {
|
||||
const created = await this.assetRepository.create({
|
||||
ownerId: asset.ownerId,
|
||||
originalPath: asset.originalPath,
|
||||
originalFileName: asset.originalFileName,
|
||||
libraryId: asset.libraryId,
|
||||
deviceAssetId: asset.deviceAssetId,
|
||||
deviceId: asset.deviceId,
|
||||
type: asset.type,
|
||||
checksum: asset.checksum,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
sidecarPath: asset.sidecarPath,
|
||||
});
|
||||
|
||||
const { size } = await this.storageRepository.stat(created.originalPath);
|
||||
await this.assetRepository.upsertExif({ assetId: created.id, fileSizeInByte: size });
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: created.id, source: 'copy' } });
|
||||
return created;
|
||||
}
|
||||
|
||||
private requireQuota(auth: AuthDto, size: number) {
|
||||
if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
|
||||
throw new BadRequestException('Quota has been exceeded!');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import { ILibraryRepository } from 'src/interfaces/library.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { UploadFile } from 'src/services/asset.service';
|
||||
import { UploadFile } from 'src/services/asset-media.service';
|
||||
import { CacheControl, ImmichFileResponse, getLivePhotoMotionFilename } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
@@ -46,24 +46,11 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { UploadRequest } from 'src/services/asset-media.service';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
export interface UploadRequest {
|
||||
auth: AuthDto | null;
|
||||
fieldName: UploadFieldName;
|
||||
file: UploadFile;
|
||||
}
|
||||
|
||||
export interface UploadFile {
|
||||
uuid: string;
|
||||
checksum: Buffer;
|
||||
originalPath: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export class AssetService {
|
||||
private access: AccessCore;
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ActivityService } from 'src/services/activity.service';
|
||||
import { AlbumService } from 'src/services/album.service';
|
||||
import { APIKeyService } from 'src/services/api-key.service';
|
||||
import { ApiService } from 'src/services/api.service';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { AssetServiceV1 } from 'src/services/asset-v1.service';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AuditService } from 'src/services/audit.service';
|
||||
@@ -41,6 +42,7 @@ export const services = [
|
||||
APIKeyService,
|
||||
ActivityService,
|
||||
AlbumService,
|
||||
AssetMediaService,
|
||||
AssetService,
|
||||
AssetServiceV1,
|
||||
AuditService,
|
||||
|
||||
@@ -250,7 +250,7 @@ export class JobService {
|
||||
}
|
||||
|
||||
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
|
||||
if (item.data.source === 'upload') {
|
||||
if (item.data.source === 'upload' || item.data.source === 'copy') {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_PREVIEW, data: item.data });
|
||||
}
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user