mirror of
https://github.com/immich-app/immich.git
synced 2026-03-27 12:20:52 +03:00
refactor: asset media endpoints (#9831)
* refactor: asset media endpoints * refactor: mobile upload livePhoto as separate request * refactor: change mobile backup flow to use new asset upload endpoints * chore: format and analyze dart code * feat: mark motion as hidden when linked * feat: upload video portion of live photo before image portion * fix: incorrect assetApi calls in mobile code * fix: download asset --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
@@ -1,21 +1,33 @@
|
||||
import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { extname } from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import {
|
||||
AssetBulkUploadCheckResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetMediaStatusEnum,
|
||||
AssetMediaStatus,
|
||||
AssetRejectReason,
|
||||
AssetUploadAction,
|
||||
CheckExistingAssetsResponseDto,
|
||||
} from 'src/dtos/asset-media-response.dto';
|
||||
import {
|
||||
AssetBulkUploadCheckDto,
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaReplaceDto,
|
||||
AssetMediaSize,
|
||||
CheckExistingAssetsDto,
|
||||
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 { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } 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';
|
||||
@@ -23,6 +35,7 @@ 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 { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
@@ -57,7 +70,121 @@ export class AssetMediaService {
|
||||
this.access = AccessCore.create(accessRepository);
|
||||
}
|
||||
|
||||
public async replaceAsset(
|
||||
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
|
||||
if (!checksum) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
|
||||
if (!assetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { id: assetId, status: AssetMediaStatus.DUPLICATE };
|
||||
}
|
||||
|
||||
canUploadFile({ auth, fieldName, file }: UploadRequest): true {
|
||||
this.access.requireUploadAccess(auth);
|
||||
|
||||
const filename = file.originalName;
|
||||
|
||||
switch (fieldName) {
|
||||
case UploadFieldName.ASSET_DATA: {
|
||||
if (mimeTypes.isAsset(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.SIDECAR_DATA: {
|
||||
if (mimeTypes.isSidecar(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.PROFILE_DATA: {
|
||||
if (mimeTypes.isProfile(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error(`Unsupported file type ${filename}`);
|
||||
throw new BadRequestException(`Unsupported file type ${filename}`);
|
||||
}
|
||||
|
||||
getUploadFilename({ auth, fieldName, file }: UploadRequest): string {
|
||||
this.access.requireUploadAccess(auth);
|
||||
|
||||
const originalExtension = extname(file.originalName);
|
||||
|
||||
const lookup = {
|
||||
[UploadFieldName.ASSET_DATA]: originalExtension,
|
||||
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
||||
[UploadFieldName.PROFILE_DATA]: originalExtension,
|
||||
};
|
||||
|
||||
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
||||
}
|
||||
|
||||
getUploadFolder({ auth, fieldName, file }: UploadRequest): string {
|
||||
auth = this.access.requireUploadAccess(auth);
|
||||
|
||||
let folder = StorageCore.getNestedFolder(StorageFolder.UPLOAD, auth.user.id, file.uuid);
|
||||
if (fieldName === UploadFieldName.PROFILE_DATA) {
|
||||
folder = StorageCore.getFolderLocation(StorageFolder.PROFILE, auth.user.id);
|
||||
}
|
||||
|
||||
this.storageRepository.mkdirSync(folder);
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
async uploadAsset(
|
||||
auth: AuthDto,
|
||||
dto: AssetMediaCreateDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
try {
|
||||
await this.access.requirePermission(
|
||||
auth,
|
||||
Permission.ASSET_UPLOAD,
|
||||
// do not need an id here, but the interface requires it
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
|
||||
if (dto.livePhotoVideoId) {
|
||||
const motionAsset = await this.assetRepository.getById(dto.livePhotoVideoId);
|
||||
if (!motionAsset) {
|
||||
throw new BadRequestException('Live photo video not found');
|
||||
}
|
||||
if (motionAsset.type !== AssetType.VIDEO) {
|
||||
throw new BadRequestException('Live photo vide must be a video');
|
||||
}
|
||||
if (motionAsset.ownerId !== auth.user.id) {
|
||||
throw new BadRequestException('Live photo video does not belong to the user');
|
||||
}
|
||||
if (motionAsset.isVisible) {
|
||||
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
|
||||
}
|
||||
}
|
||||
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
|
||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||
} catch (error: any) {
|
||||
return this.handleUploadError(error, auth, file, sidecarFile);
|
||||
}
|
||||
}
|
||||
|
||||
async replaceAsset(
|
||||
auth: AuthDto,
|
||||
id: string,
|
||||
dto: AssetMediaReplaceDto,
|
||||
@@ -66,27 +193,131 @@ export class AssetMediaService {
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
try {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id);
|
||||
const existingAssetEntity = (await this.assetRepository.getById(id)) as AssetEntity;
|
||||
const asset = (await this.assetRepository.getById(id)) as AssetEntity;
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
|
||||
await this.replaceFileData(existingAssetEntity.id, dto, file, sidecarFile?.originalPath);
|
||||
await this.replaceFileData(asset.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);
|
||||
const copiedPhoto = await this.createCopy(asset);
|
||||
// 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 };
|
||||
return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
|
||||
} catch (error: any) {
|
||||
return await this.handleUploadError(error, auth, file, sidecarFile);
|
||||
return this.handleUploadError(error, auth, file, sidecarFile);
|
||||
}
|
||||
}
|
||||
|
||||
async downloadOriginal(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id);
|
||||
|
||||
const asset = await this.findOrFail(id);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset does not exist');
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: asset.originalPath,
|
||||
contentType: mimeTypes.lookup(asset.originalPath),
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
});
|
||||
}
|
||||
|
||||
async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise<ImmichFileResponse> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_VIEW, id);
|
||||
|
||||
const asset = await this.findOrFail(id);
|
||||
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
|
||||
|
||||
let filepath = asset.previewPath;
|
||||
if (size === AssetMediaSize.THUMBNAIL && asset.thumbnailPath) {
|
||||
filepath = asset.thumbnailPath;
|
||||
}
|
||||
|
||||
if (!filepath) {
|
||||
throw new NotFoundException('Asset media not found');
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: filepath,
|
||||
contentType: mimeTypes.lookup(filepath),
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
});
|
||||
}
|
||||
|
||||
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_VIEW, id);
|
||||
|
||||
const asset = await this.findOrFail(id);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset does not exist');
|
||||
}
|
||||
|
||||
if (asset.type !== AssetType.VIDEO) {
|
||||
throw new BadRequestException('Asset is not a video');
|
||||
}
|
||||
|
||||
const filepath = asset.encodedVideoPath || asset.originalPath;
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: filepath,
|
||||
contentType: mimeTypes.lookup(filepath),
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
});
|
||||
}
|
||||
|
||||
async checkExistingAssets(
|
||||
auth: AuthDto,
|
||||
checkExistingAssetsDto: CheckExistingAssetsDto,
|
||||
): Promise<CheckExistingAssetsResponseDto> {
|
||||
const assets = await this.assetRepository.getByDeviceIds(
|
||||
auth.user.id,
|
||||
checkExistingAssetsDto.deviceId,
|
||||
checkExistingAssetsDto.deviceAssetIds,
|
||||
);
|
||||
return {
|
||||
existingIds: assets.map((asset) => asset.id),
|
||||
};
|
||||
}
|
||||
|
||||
async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> {
|
||||
const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum));
|
||||
const results = await this.assetRepository.getByChecksums(auth.user.id, checksums);
|
||||
const checksumMap: Record<string, string> = {};
|
||||
|
||||
for (const { id, checksum } of results) {
|
||||
checksumMap[checksum.toString('hex')] = id;
|
||||
}
|
||||
|
||||
return {
|
||||
results: dto.assets.map(({ id, checksum }) => {
|
||||
const duplicate = checksumMap[fromChecksum(checksum).toString('hex')];
|
||||
if (duplicate) {
|
||||
return {
|
||||
id,
|
||||
assetId: duplicate,
|
||||
action: AssetUploadAction.REJECT,
|
||||
reason: AssetRejectReason.DUPLICATE,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO mime-check
|
||||
|
||||
return {
|
||||
id,
|
||||
action: AssetUploadAction.ACCEPT,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async handleUploadError(
|
||||
error: any,
|
||||
auth: AuthDto,
|
||||
@@ -106,7 +337,7 @@ export class AssetMediaService {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
return { status: AssetMediaStatusEnum.DUPLICATE, id: duplicateId };
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
@@ -181,54 +412,59 @@ export class AssetMediaService {
|
||||
return created;
|
||||
}
|
||||
|
||||
private async create(
|
||||
ownerId: string,
|
||||
dto: AssetMediaCreateDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
): Promise<AssetEntity> {
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId,
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
isFavorite: dto.isFavorite,
|
||||
isArchived: dto.isArchived ?? false,
|
||||
duration: dto.duration || null,
|
||||
isVisible: dto.isVisible ?? true,
|
||||
livePhotoVideoId: dto.livePhotoVideoId,
|
||||
originalFileName: file.originalName,
|
||||
sidecarPath: sidecarFile?.originalPath,
|
||||
isOffline: dto.isOffline ?? false,
|
||||
});
|
||||
|
||||
if (sidecarFile) {
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
private requireQuota(auth: AuthDto, size: number) {
|
||||
if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
|
||||
throw new BadRequestException('Quota has been exceeded!');
|
||||
}
|
||||
}
|
||||
|
||||
async checkExistingAssets(
|
||||
auth: AuthDto,
|
||||
checkExistingAssetsDto: CheckExistingAssetsDto,
|
||||
): Promise<CheckExistingAssetsResponseDto> {
|
||||
const assets = await this.assetRepository.getByDeviceIds(
|
||||
auth.user.id,
|
||||
checkExistingAssetsDto.deviceId,
|
||||
checkExistingAssetsDto.deviceAssetIds,
|
||||
);
|
||||
return {
|
||||
existingIds: assets.map((asset) => asset.id),
|
||||
};
|
||||
}
|
||||
|
||||
async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> {
|
||||
const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum));
|
||||
const results = await this.assetRepository.getByChecksums(auth.user.id, checksums);
|
||||
const checksumMap: Record<string, string> = {};
|
||||
|
||||
for (const { id, checksum } of results) {
|
||||
checksumMap[checksum.toString('hex')] = id;
|
||||
private async findOrFail(id: string): Promise<AssetEntity> {
|
||||
const asset = await this.assetRepository.getById(id);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found');
|
||||
}
|
||||
|
||||
return {
|
||||
results: dto.assets.map(({ id, checksum }) => {
|
||||
const duplicate = checksumMap[fromChecksum(checksum).toString('hex')];
|
||||
if (duplicate) {
|
||||
return {
|
||||
id,
|
||||
assetId: duplicate,
|
||||
action: AssetUploadAction.REJECT,
|
||||
reason: AssetRejectReason.DUPLICATE,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO mime-check
|
||||
|
||||
return {
|
||||
id,
|
||||
action: AssetUploadAction.ACCEPT,
|
||||
};
|
||||
}),
|
||||
};
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user