refactor(server): mime types (#3197)

* refactor(server): mime type check

* chore: open api

* chore: remove duplicate test
This commit is contained in:
Jason Rasmussen
2023-07-10 13:56:45 -04:00
committed by GitHub
parent 785f61ba70
commit 6180828ed2
26 changed files with 287 additions and 324 deletions

View File

@@ -156,10 +156,7 @@ describe(AssetService.name, () => {
await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual({ stream });
expect(storageMock.createReadStream).toHaveBeenCalledWith(
assetEntityStub.image.originalPath,
assetEntityStub.image.mimeType,
);
expect(storageMock.createReadStream).toHaveBeenCalledWith(assetEntityStub.image.originalPath, 'image/jpeg');
});
it('should download an archive', async () => {

View File

@@ -4,6 +4,7 @@ import { DateTime } from 'luxon';
import { extname } from 'path';
import { AccessCore, IAccessRepository, Permission } from '../access';
import { AuthUserDto } from '../auth';
import { mimeTypes } from '../domain.constant';
import { HumanReadableSize, usePagination } from '../domain.util';
import { ImmichReadStream, IStorageRepository } from '../storage';
import { IAssetRepository } from './asset.repository';
@@ -20,7 +21,6 @@ export enum UploadFieldName {
}
export interface UploadFile {
mimeType: string;
checksum: Buffer;
originalPath: string;
originalName: string;
@@ -68,7 +68,7 @@ export class AssetService {
throw new BadRequestException('Asset not found');
}
return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType);
return this.storageRepository.createReadStream(asset.originalPath, mimeTypes.lookup(asset.originalPath));
}
async getDownloadInfo(authUser: AuthUserDto, dto: DownloadDto): Promise<DownloadResponseDto> {

View File

@@ -23,7 +23,6 @@ export class AssetResponseDto {
updatedAt!: Date;
isFavorite!: boolean;
isArchived!: boolean;
mimeType!: string | null;
duration!: string;
exifInfo?: ExifResponseDto;
smartInfo?: SmartInfoResponseDto;
@@ -50,7 +49,6 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
updatedAt: entity.updatedAt,
isFavorite: entity.isFavorite,
isArchived: entity.isArchived,
mimeType: entity.mimeType,
duration: entity.duration ?? '0:00:00.00000',
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
@@ -77,7 +75,6 @@ export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
updatedAt: entity.updatedAt,
isFavorite: entity.isFavorite,
isArchived: entity.isArchived,
mimeType: entity.mimeType,
duration: entity.duration ?? '0:00:00.00000',
exifInfo: undefined,
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,

View File

@@ -1,4 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { extname } from 'node:path';
import pkg from 'src/../../package.json';
const [major, minor, patch] = pkg.version.split('.');
@@ -28,92 +29,78 @@ export function assertMachineLearningEnabled() {
}
}
export const ASSET_MIME_TYPES = [
'image/3fr',
'image/ari',
'image/arw',
'image/avif',
'image/cap',
'image/cin',
'image/cr2',
'image/cr3',
'image/crw',
'image/dcr',
'image/dng',
'image/erf',
'image/fff',
'image/gif',
'image/heic',
'image/heif',
'image/iiq',
'image/jpeg',
'image/jxl',
'image/k25',
'image/kdc',
'image/mrw',
'image/nef',
'image/orf',
'image/ori',
'image/pef',
'image/png',
'image/raf',
'image/raw',
'image/rwl',
'image/sr2',
'image/srf',
'image/srw',
'image/tiff',
'image/webp',
'image/x-adobe-dng',
'image/x-arriflex-ari',
'image/x-canon-cr2',
'image/x-canon-cr3',
'image/x-canon-crw',
'image/x-epson-erf',
'image/x-fuji-raf',
'image/x-hasselblad-3fr',
'image/x-hasselblad-fff',
'image/x-kodak-dcr',
'image/x-kodak-k25',
'image/x-kodak-kdc',
'image/x-leica-rwl',
'image/x-minolta-mrw',
'image/x-nikon-nef',
'image/x-olympus-orf',
'image/x-olympus-ori',
'image/x-panasonic-raw',
'image/x-pentax-pef',
'image/x-phantom-cin',
'image/x-phaseone-cap',
'image/x-phaseone-iiq',
'image/x-samsung-srw',
'image/x-sigma-x3f',
'image/x-sony-arw',
'image/x-sony-sr2',
'image/x-sony-srf',
'image/x3f',
'video/3gpp',
'video/avi',
'video/mp2t',
'video/mp4',
'video/mpeg',
'video/msvideo',
'video/quicktime',
'video/vnd.avi',
'video/webm',
'video/x-flv',
'video/x-matroska',
'video/x-ms-wmv',
'video/x-msvideo',
];
export const LIVE_PHOTO_MIME_TYPES = ASSET_MIME_TYPES;
export const SIDECAR_MIME_TYPES = ['application/xml', 'text/xml'];
export const PROFILE_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/heic',
'image/heif',
'image/dng',
'image/webp',
'image/avif',
];
const profile: Record<string, string> = {
'.avif': 'image/avif',
'.dng': 'image/x-adobe-dng',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
};
const image: Record<string, string> = {
...profile,
'.3fr': 'image/x-hasselblad-3fr',
'.ari': 'image/x-arriflex-ari',
'.arw': 'image/x-sony-arw',
'.cap': 'image/x-phaseone-cap',
'.cin': 'image/x-phantom-cin',
'.cr2': 'image/x-canon-cr2',
'.cr3': 'image/x-canon-cr3',
'.crw': 'image/x-canon-crw',
'.dcr': 'image/x-kodak-dcr',
'.erf': 'image/x-epson-erf',
'.fff': 'image/x-hasselblad-fff',
'.gif': 'image/gif',
'.iiq': 'image/x-phaseone-iiq',
'.k25': 'image/x-kodak-k25',
'.kdc': 'image/x-kodak-kdc',
'.mrw': 'image/x-minolta-mrw',
'.nef': 'image/x-nikon-nef',
'.orf': 'image/x-olympus-orf',
'.ori': 'image/x-olympus-ori',
'.pef': 'image/x-pentax-pef',
'.raf': 'image/x-fuji-raf',
'.raw': 'image/x-panasonic-raw',
'.rwl': 'image/x-leica-rwl',
'.sr2': 'image/x-sony-sr2',
'.srf': 'image/x-sony-srf',
'.srw': 'image/x-samsung-srw',
'.tiff': 'image/tiff',
'.x3f': 'image/x-sigma-x3f',
};
const video: Record<string, string> = {
'.3gp': 'video/3gpp',
'.avi': 'video/x-msvideo',
'.flv': 'video/x-flv',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.mp2t': 'video/mp2t',
'.mp4': 'video/mp4',
'.mpeg': 'video/mpeg',
'.webm': 'video/webm',
'.wmv': 'video/x-ms-wmv',
};
const sidecar: Record<string, string> = {
'.xmp': 'application/xml',
};
const isType = (filename: string, lookup: Record<string, string>) => !!lookup[extname(filename).toLowerCase()];
const getType = (filename: string, lookup: Record<string, string>) => lookup[extname(filename).toLowerCase()];
export const mimeTypes = {
image,
profile,
sidecar,
video,
isAsset: (filename: string) => isType(filename, image) || isType(filename, video),
isProfile: (filename: string) => isType(filename, profile),
isSidecar: (filename: string) => isType(filename, sidecar),
isVideo: (filename: string) => isType(filename, video),
lookup: (filename: string) => getType(filename, { ...image, ...video, ...sidecar }) || 'application/octet-stream',
};

View File

@@ -268,7 +268,7 @@ describe(FacialRecognitionService.name, () => {
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
left: 95,
top: 95,
width: 110,
@@ -289,7 +289,7 @@ describe(FacialRecognitionService.name, () => {
await sut.handleGenerateFaceThumbnail(face.start);
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
left: 0,
top: 0,
width: 510,
@@ -306,7 +306,7 @@ describe(FacialRecognitionService.name, () => {
await sut.handleGenerateFaceThumbnail(face.end);
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
left: 297,
top: 297,
width: 202,

View File

@@ -116,7 +116,7 @@ describe(MediaService.name, () => {
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/asset-id.jpeg', {
size: 1440,
format: 'jpeg',
});
@@ -167,11 +167,11 @@ describe(MediaService.name, () => {
await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.image.id });
expect(mediaMock.resize).toHaveBeenCalledWith(
'/uploads/user-id/thumbs/path.ext',
'/uploads/user-id/thumbs/path.ext',
'/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/thumbs/path.webp',
{ format: 'webp', size: 250 },
);
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.ext' });
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.webp' });
});
});
@@ -195,7 +195,7 @@ describe(MediaService.name, () => {
await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.image.id });
expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext');
expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg');
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
});
});

View File

@@ -89,10 +89,10 @@ export class MediaService {
return false;
}
const webpPath = asset.resizePath.replace('jpeg', 'webp');
const webpPath = asset.resizePath.replace('jpeg', 'webp').replace('jpg', 'webp');
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' });
await this.assetRepository.save({ id: asset.id, webpPath: webpPath });
await this.assetRepository.save({ id: asset.id, webpPath });
return true;
}

View File

@@ -82,10 +82,10 @@ describe(MetadataService.name, () => {
assetMock.save.mockResolvedValue(assetEntityStub.image);
storageMock.checkFileExists.mockResolvedValue(true);
await sut.handleSidecarDiscovery({ id: assetEntityStub.image.id });
expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK);
expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK);
expect(assetMock.save).toHaveBeenCalledWith({
id: assetEntityStub.image.id,
sidecarPath: '/original/path.ext.xmp',
sidecarPath: '/original/path.jpg.xmp',
});
});

View File

@@ -17,7 +17,7 @@ import { PersonService } from './person.service';
const responseDto: PersonResponseDto = {
id: 'person-1',
name: 'Person 1',
thumbnailPath: '/path/to/thumbnail',
thumbnailPath: '/path/to/thumbnail.jpg',
};
describe(PersonService.name, () => {
@@ -74,7 +74,7 @@ describe(PersonService.name, () => {
it('should serve the thumbnail', async () => {
personMock.getById.mockResolvedValue(personStub.noName);
await sut.getThumbnail(authStub.admin, 'person-1');
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail', 'image/jpeg');
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg');
});
});
@@ -150,7 +150,7 @@ describe(PersonService.name, () => {
expect(personMock.delete).toHaveBeenCalledWith(personStub.noName);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES,
data: { files: ['/path/to/thumbnail'] },
data: { files: ['/path/to/thumbnail.jpg'] },
});
});
});

View File

@@ -2,6 +2,7 @@ import { PersonEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AssetResponseDto, mapAsset } from '../asset';
import { AuthUserDto } from '../auth';
import { mimeTypes } from '../domain.constant';
import { IJobRepository, JobName } from '../job';
import { ImmichReadStream, IStorageRepository } from '../storage';
import { mapPerson, PersonResponseDto, PersonUpdateDto } from './person.dto';
@@ -44,7 +45,7 @@ export class PersonService {
throw new NotFoundException();
}
return this.storageRepository.createReadStream(person.thumbnailPath, 'image/jpeg');
return this.storageRepository.createReadStream(person.thumbnailPath, mimeTypes.lookup(person.thumbnailPath));
}
async getAssets(authUser: AuthUserDto, personId: string): Promise<AssetResponseDto[]> {

View File

@@ -56,11 +56,11 @@ describe(StorageTemplateService.name, () => {
userMock.getList.mockResolvedValue([userEntityStub.user1]);
when(storageMock.checkFileExists)
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id.ext')
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg')
.mockResolvedValue(true);
when(storageMock.checkFileExists)
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id+1.ext')
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id+1.jpg')
.mockResolvedValue(false);
await sut.handleMigration();
@@ -69,7 +69,7 @@ describe(StorageTemplateService.name, () => {
expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2);
expect(assetMock.save).toHaveBeenCalledWith({
id: assetEntityStub.image.id,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
});
expect(userMock.getList).toHaveBeenCalled();
});
@@ -79,7 +79,7 @@ describe(StorageTemplateService.name, () => {
items: [
{
...assetEntityStub.image,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext',
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
},
],
hasNextPage: false,
@@ -99,7 +99,7 @@ describe(StorageTemplateService.name, () => {
items: [
{
...assetEntityStub.image,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
},
],
hasNextPage: false,
@@ -126,12 +126,12 @@ describe(StorageTemplateService.name, () => {
expect(assetMock.getAll).toHaveBeenCalled();
expect(storageMock.moveFile).toHaveBeenCalledWith(
'/original/path.ext',
'upload/library/user-id/2023/2023-02-23/asset-id.ext',
'/original/path.jpg',
'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
);
expect(assetMock.save).toHaveBeenCalledWith({
id: assetEntityStub.image.id,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext',
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
});
});
@@ -147,12 +147,12 @@ describe(StorageTemplateService.name, () => {
expect(assetMock.getAll).toHaveBeenCalled();
expect(storageMock.moveFile).toHaveBeenCalledWith(
'/original/path.ext',
'upload/library/label-1/2023/2023-02-23/asset-id.ext',
'/original/path.jpg',
'upload/library/label-1/2023/2023-02-23/asset-id.jpg',
);
expect(assetMock.save).toHaveBeenCalledWith({
id: assetEntityStub.image.id,
originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.ext',
originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.jpg',
});
});
@@ -168,8 +168,8 @@ describe(StorageTemplateService.name, () => {
expect(assetMock.getAll).toHaveBeenCalled();
expect(storageMock.moveFile).toHaveBeenCalledWith(
'/original/path.ext',
'upload/library/user-id/2023/2023-02-23/asset-id.ext',
'/original/path.jpg',
'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
);
expect(assetMock.save).not.toHaveBeenCalled();
});
@@ -187,11 +187,11 @@ describe(StorageTemplateService.name, () => {
expect(assetMock.getAll).toHaveBeenCalled();
expect(assetMock.save).toHaveBeenCalledWith({
id: assetEntityStub.image.id,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext',
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
});
expect(storageMock.moveFile.mock.calls).toEqual([
['/original/path.ext', 'upload/library/user-id/2023/2023-02-23/asset-id.ext'],
['upload/library/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'],
['/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'],
]);
});
@@ -200,7 +200,7 @@ describe(StorageTemplateService.name, () => {
items: [
{
...assetEntityStub.image,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
isReadOnly: true,
},
],