diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index fa0cccb209..4868919c97 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -12,7 +12,6 @@ import { import { when } from 'jest-when'; import { Readable } from 'stream'; import { ICryptoRepository } from '../crypto'; -import { mimeTypes } from '../domain.constant'; import { IStorageRepository } from '../storage'; import { AssetStats, IAssetRepository } from './asset.repository'; import { AssetService, UploadFieldName } from './asset.service'; @@ -66,30 +65,78 @@ const uploadFile = { }, }; +const validImages = [ + '.3fr', + '.ari', + '.arw', + '.avif', + '.cap', + '.cin', + '.cr2', + '.cr3', + '.crw', + '.dcr', + '.dng', + '.erf', + '.fff', + '.gif', + '.heic', + '.heif', + '.iiq', + '.jpeg', + '.jpg', + '.jxl', + '.k25', + '.kdc', + '.mrw', + '.nef', + '.orf', + '.ori', + '.pef', + '.png', + '.raf', + '.raw', + '.rwl', + '.sr2', + '.srf', + '.srw', + '.tiff', + '.webp', + '.x3f', +]; + +const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv']; + const uploadTests = [ { - label: 'asset', + label: 'asset images', fieldName: UploadFieldName.ASSET_DATA, - filetypes: Object.keys({ ...mimeTypes.image, ...mimeTypes.video }), - invalid: ['.xml', '.html'], + valid: validImages, + invalid: ['.html', '.xml'], + }, + { + label: 'asset videos', + fieldName: UploadFieldName.ASSET_DATA, + valid: validVideos, + invalid: ['.html', '.xml'], }, { label: 'live photo', fieldName: UploadFieldName.LIVE_PHOTO_DATA, - filetypes: Object.keys(mimeTypes.video), - invalid: ['.xml', '.html', '.jpg', '.jpeg'], + valid: validVideos, + invalid: ['.html', '.jpeg', '.jpg', '.xml'], }, { label: 'sidecar', fieldName: UploadFieldName.SIDECAR_DATA, - filetypes: Object.keys(mimeTypes.sidecar), - invalid: ['.xml', '.html', '.jpg', '.jpeg', '.mov', '.mp4'], + valid: ['.xmp'], + invalid: ['.html', '.jpeg', '.jpg', '.mov', '.mp4', '.xml'], }, { label: 'profile', fieldName: UploadFieldName.PROFILE_DATA, - filetypes: Object.keys(mimeTypes.profile), - invalid: ['.xml', '.html', '.cr2', '.arf', '.mov', '.mp4'], + valid: ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'], + invalid: ['.arf', '.cr2', '.html', '.mov', '.mp4', '.xml'], }, ]; @@ -117,9 +164,9 @@ describe(AssetService.name, () => { expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException); }); - for (const { fieldName, filetypes, invalid } of uploadTests) { - describe(`${fieldName}`, () => { - for (const filetype of filetypes) { + for (const { fieldName, valid, invalid } of uploadTests) { + describe(fieldName, () => { + for (const filetype of valid) { it(`should accept ${filetype}`, () => { expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true); }); @@ -132,6 +179,16 @@ describe(AssetService.name, () => { ); }); } + + it('should be sorted (valid)', () => { + // TODO: use toSorted in NodeJS 20. + expect(valid).toEqual([...valid].sort()); + }); + + it('should be sorted (invalid)', () => { + // TODO: use toSorted in NodeJS 20. + expect(invalid).toEqual([...invalid].sort()); + }); }); } }); diff --git a/server/src/domain/domain.constant.spec.ts b/server/src/domain/domain.constant.spec.ts new file mode 100644 index 0000000000..36cec5e1b6 --- /dev/null +++ b/server/src/domain/domain.constant.spec.ts @@ -0,0 +1,191 @@ +import { mimeTypes } from '@app/domain'; + +describe('mimeTypes', () => { + for (const { mimetype, extension } of [ + // Please ensure this list is sorted. + { mimetype: 'image/3fr', extension: '.3fr' }, + { mimetype: 'image/ari', extension: '.ari' }, + { mimetype: 'image/arw', extension: '.arw' }, + { mimetype: 'image/avif', extension: '.avif' }, + { mimetype: 'image/cap', extension: '.cap' }, + { mimetype: 'image/cin', extension: '.cin' }, + { mimetype: 'image/cr2', extension: '.cr2' }, + { mimetype: 'image/cr3', extension: '.cr3' }, + { mimetype: 'image/crw', extension: '.crw' }, + { mimetype: 'image/dcr', extension: '.dcr' }, + { mimetype: 'image/dng', extension: '.dng' }, + { mimetype: 'image/erf', extension: '.erf' }, + { mimetype: 'image/fff', extension: '.fff' }, + { mimetype: 'image/gif', extension: '.gif' }, + { mimetype: 'image/heic', extension: '.heic' }, + { mimetype: 'image/heif', extension: '.heif' }, + { mimetype: 'image/iiq', extension: '.iiq' }, + { mimetype: 'image/jpeg', extension: '.jpeg' }, + { mimetype: 'image/jpeg', extension: '.jpg' }, + { mimetype: 'image/jxl', extension: '.jxl' }, + { mimetype: 'image/k25', extension: '.k25' }, + { mimetype: 'image/kdc', extension: '.kdc' }, + { mimetype: 'image/mrw', extension: '.mrw' }, + { mimetype: 'image/nef', extension: '.nef' }, + { mimetype: 'image/orf', extension: '.orf' }, + { mimetype: 'image/ori', extension: '.ori' }, + { mimetype: 'image/pef', extension: '.pef' }, + { mimetype: 'image/png', extension: '.png' }, + { mimetype: 'image/raf', extension: '.raf' }, + { mimetype: 'image/raw', extension: '.raw' }, + { mimetype: 'image/rwl', extension: '.rwl' }, + { mimetype: 'image/sr2', extension: '.sr2' }, + { mimetype: 'image/srf', extension: '.srf' }, + { mimetype: 'image/srw', extension: '.srw' }, + { mimetype: 'image/tiff', extension: '.tiff' }, + { mimetype: 'image/webp', extension: '.webp' }, + { mimetype: 'image/x-adobe-dng', extension: '.dng' }, + { mimetype: 'image/x-arriflex-ari', extension: '.ari' }, + { mimetype: 'image/x-canon-cr2', extension: '.cr2' }, + { mimetype: 'image/x-canon-cr3', extension: '.cr3' }, + { mimetype: 'image/x-canon-crw', extension: '.crw' }, + { mimetype: 'image/x-epson-erf', extension: '.erf' }, + { mimetype: 'image/x-fuji-raf', extension: '.raf' }, + { mimetype: 'image/x-hasselblad-3fr', extension: '.3fr' }, + { mimetype: 'image/x-hasselblad-fff', extension: '.fff' }, + { mimetype: 'image/x-kodak-dcr', extension: '.dcr' }, + { mimetype: 'image/x-kodak-k25', extension: '.k25' }, + { mimetype: 'image/x-kodak-kdc', extension: '.kdc' }, + { mimetype: 'image/x-leica-rwl', extension: '.rwl' }, + { mimetype: 'image/x-minolta-mrw', extension: '.mrw' }, + { mimetype: 'image/x-nikon-nef', extension: '.nef' }, + { mimetype: 'image/x-olympus-orf', extension: '.orf' }, + { mimetype: 'image/x-olympus-ori', extension: '.ori' }, + { mimetype: 'image/x-panasonic-raw', extension: '.raw' }, + { mimetype: 'image/x-pentax-pef', extension: '.pef' }, + { mimetype: 'image/x-phantom-cin', extension: '.cin' }, + { mimetype: 'image/x-phaseone-cap', extension: '.cap' }, + { mimetype: 'image/x-phaseone-iiq', extension: '.iiq' }, + { mimetype: 'image/x-samsung-srw', extension: '.srw' }, + { mimetype: 'image/x-sigma-x3f', extension: '.x3f' }, + { mimetype: 'image/x-sony-arw', extension: '.arw' }, + { mimetype: 'image/x-sony-sr2', extension: '.sr2' }, + { mimetype: 'image/x-sony-srf', extension: '.srf' }, + { mimetype: 'image/x3f', extension: '.x3f' }, + { mimetype: 'video/3gpp', extension: '.3gp' }, + { mimetype: 'video/avi', extension: '.avi' }, + { mimetype: 'video/mp2t', extension: '.m2ts' }, + { mimetype: 'video/mp2t', extension: '.mts' }, + { mimetype: 'video/mp4', extension: '.mp4' }, + { mimetype: 'video/mpeg', extension: '.mpg' }, + { mimetype: 'video/msvideo', extension: '.avi' }, + { mimetype: 'video/quicktime', extension: '.mov' }, + { mimetype: 'video/vnd.avi', extension: '.avi' }, + { mimetype: 'video/webm', extension: '.webm' }, + { mimetype: 'video/x-flv', extension: '.flv' }, + { mimetype: 'video/x-matroska', extension: '.mkv' }, + { mimetype: 'video/x-ms-wmv', extension: '.wmv' }, + { mimetype: 'video/x-msvideo', extension: '.avi' }, + ]) { + it(`should map ${extension} to ${mimetype}`, async () => { + expect({ ...mimeTypes.image, ...mimeTypes.video }[extension]).toContain(mimetype); + }); + } + + describe('profile', () => { + it('should contain only lowercase mime types', () => { + const keys = Object.keys(mimeTypes.profile); + expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); + + const values = Object.values(mimeTypes.profile).flat(); + expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); + }); + + it('should be a sorted list', () => { + const keys = Object.keys(mimeTypes.profile); + // TODO: use toSorted in NodeJS 20. + expect(keys).toEqual([...keys].sort()); + }); + + for (const [ext, v] of Object.entries(mimeTypes.profile)) { + it(`should lookup ${ext}`, () => { + expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]); + }); + } + }); + + describe('image', () => { + it('should contain only lowercase mime types', () => { + const keys = Object.keys(mimeTypes.image); + expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); + + const values = Object.values(mimeTypes.image).flat(); + expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); + }); + + it('should be a sorted list', () => { + const keys = Object.keys(mimeTypes.image); + // TODO: use toSorted in NodeJS 20. + expect(keys).toEqual([...keys].sort()); + }); + + it('should contain only image mime types', () => { + const values = Object.values(mimeTypes.image).flat(); + expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('image/'))); + }); + + for (const [ext, v] of Object.entries(mimeTypes.image)) { + it(`should lookup ${ext}`, () => { + expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]); + }); + } + }); + + describe('video', () => { + it('should contain only lowercase mime types', () => { + const keys = Object.keys(mimeTypes.video); + expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); + + const values = Object.values(mimeTypes.video).flat(); + expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); + }); + + it('should be a sorted list', () => { + const keys = Object.keys(mimeTypes.video); + // TODO: use toSorted in NodeJS 20. + expect(keys).toEqual([...keys].sort()); + }); + + it('should contain only video mime types', () => { + const values = Object.values(mimeTypes.video).flat(); + expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('video/'))); + }); + + for (const [ext, v] of Object.entries(mimeTypes.video)) { + it(`should lookup ${ext}`, () => { + expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]); + }); + } + }); + + describe('sidecar', () => { + it('should contain only lowercase mime types', () => { + const keys = Object.keys(mimeTypes.sidecar); + expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); + + const values = Object.values(mimeTypes.sidecar).flat(); + expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); + }); + + it('should be a sorted list', () => { + const keys = Object.keys(mimeTypes.sidecar); + // TODO: use toSorted in NodeJS 20. + expect(keys).toEqual([...keys].sort()); + }); + + it('should contain only xml mime types', () => { + expect(Object.values(mimeTypes.sidecar).flat()).toEqual(['application/xml', 'text/xml']); + }); + + for (const [ext, v] of Object.entries(mimeTypes.sidecar)) { + it(`should lookup ${ext}`, () => { + expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]); + }); + } + }); +}); diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index 734111dc89..a1cb0a7dfe 100644 --- a/server/src/domain/domain.constant.ts +++ b/server/src/domain/domain.constant.ts @@ -30,70 +30,73 @@ export function assertMachineLearningEnabled() { } } -const profile: Record = { - '.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 = { + '.3fr': ['image/3fr', 'image/x-hasselblad-3fr'], + '.ari': ['image/ari', 'image/x-arriflex-ari'], + '.arw': ['image/arw', 'image/x-sony-arw'], + '.avif': ['image/avif'], + '.cap': ['image/cap', 'image/x-phaseone-cap'], + '.cin': ['image/cin', 'image/x-phantom-cin'], + '.cr2': ['image/cr2', 'image/x-canon-cr2'], + '.cr3': ['image/cr3', 'image/x-canon-cr3'], + '.crw': ['image/crw', 'image/x-canon-crw'], + '.dcr': ['image/dcr', 'image/x-kodak-dcr'], + '.dng': ['image/dng', 'image/x-adobe-dng'], + '.erf': ['image/erf', 'image/x-epson-erf'], + '.fff': ['image/fff', 'image/x-hasselblad-fff'], + '.gif': ['image/gif'], + '.heic': ['image/heic'], + '.heif': ['image/heif'], + '.iiq': ['image/iiq', 'image/x-phaseone-iiq'], + '.jpeg': ['image/jpeg'], + '.jpg': ['image/jpeg'], + '.jxl': ['image/jxl'], + '.k25': ['image/k25', 'image/x-kodak-k25'], + '.kdc': ['image/kdc', 'image/x-kodak-kdc'], + '.mrw': ['image/mrw', 'image/x-minolta-mrw'], + '.nef': ['image/nef', 'image/x-nikon-nef'], + '.orf': ['image/orf', 'image/x-olympus-orf'], + '.ori': ['image/ori', 'image/x-olympus-ori'], + '.pef': ['image/pef', 'image/x-pentax-pef'], + '.png': ['image/png'], + '.raf': ['image/raf', 'image/x-fuji-raf'], + '.raw': ['image/raw', 'image/x-panasonic-raw'], + '.rwl': ['image/rwl', 'image/x-leica-rwl'], + '.sr2': ['image/sr2', 'image/x-sony-sr2'], + '.srf': ['image/srf', 'image/x-sony-srf'], + '.srw': ['image/srw', 'image/x-samsung-srw'], + '.tiff': ['image/tiff'], + '.webp': ['image/webp'], + '.x3f': ['image/x3f', 'image/x-sigma-x3f'], }; -const image: Record = { - ...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 profileExtensions = ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp']; +const profile: Record = Object.fromEntries( + Object.entries(image).filter(([key]) => profileExtensions.includes(key)), +); + +const video: Record = { + '.3gp': ['video/3gpp'], + '.avi': ['video/avi', 'video/msvideo', 'video/vnd.avi', 'video/x-msvideo'], + '.flv': ['video/x-flv'], + '.m2ts': ['video/mp2t'], + '.mkv': ['video/x-matroska'], + '.mov': ['video/quicktime'], + '.mp4': ['video/mp4'], + '.mpg': ['video/mpeg'], + '.mts': ['video/mp2t'], + '.webm': ['video/webm'], + '.wmv': ['video/x-ms-wmv'], }; -const video: Record = { - '.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 = { + '.xmp': ['application/xml', 'text/xml'], }; -const sidecar: Record = { - '.xmp': 'application/xml', -}; +const isType = (filename: string, r: Record) => extname(filename).toLowerCase() in r; -const isType = (filename: string, lookup: Record) => !!lookup[extname(filename).toLowerCase()]; -const getType = (filename: string, lookup: Record) => lookup[extname(filename).toLowerCase()]; const lookup = (filename: string) => - getType(filename, { ...image, ...video, ...sidecar }) || 'application/octet-stream'; + ({ ...image, ...video, ...sidecar }[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream'); export const mimeTypes = { image, @@ -107,14 +110,12 @@ export const mimeTypes = { isVideo: (filename: string) => isType(filename, video), lookup, assetType: (filename: string) => { - const contentType = lookup(filename).split('/')[0]; - switch (contentType) { - case 'image': - return AssetType.IMAGE; - case 'video': - return AssetType.VIDEO; - default: - return AssetType.OTHER; + const contentType = lookup(filename); + if (contentType.startsWith('image/')) { + return AssetType.IMAGE; + } else if (contentType.startsWith('video/')) { + return AssetType.VIDEO; } + return AssetType.OTHER; }, }; diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 5528f7bcc6..913de5a87f 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -1,4 +1,4 @@ -import { ICryptoRepository, IJobRepository, IStorageRepository, JobName, mimeTypes } from '@app/domain'; +import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { BadRequestException } from '@nestjs/common'; import { @@ -139,84 +139,6 @@ describe('AssetService', () => { .mockResolvedValue(assetEntityStub.livePhotoMotionAsset); }); - describe('mime types linting', () => { - describe('profile', () => { - it('should contain only lowercase mime types', () => { - const keys = Object.keys(mimeTypes.profile); - expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); - const values = Object.values(mimeTypes.profile); - expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); - }); - - it('should be a sorted list', () => { - const keys = Object.keys(mimeTypes.profile); - expect(keys).toEqual([...keys].sort()); - }); - }); - - describe('image', () => { - it('should contain only lowercase mime types', () => { - const keys = Object.keys(mimeTypes.image); - expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); - const values = Object.values(mimeTypes.image); - expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); - }); - - it('should be a sorted list', () => { - const keys = Object.keys(mimeTypes.image).filter((key) => key in mimeTypes.profile === false); - expect(keys).toEqual([...keys].sort()); - }); - - it('should contain only image mime types', () => { - expect(Object.values(mimeTypes.image)).toEqual( - Object.values(mimeTypes.image).filter((mimeType) => mimeType.startsWith('image/')), - ); - }); - }); - - describe('video', () => { - it('should contain only lowercase mime types', () => { - const keys = Object.keys(mimeTypes.video); - expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); - const values = Object.values(mimeTypes.video); - expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); - }); - - it('should be a sorted list', () => { - const keys = Object.keys(mimeTypes.video); - expect(keys).toEqual([...keys].sort()); - }); - - it('should contain only video mime types', () => { - expect(Object.values(mimeTypes.video)).toEqual( - Object.values(mimeTypes.video).filter((mimeType) => mimeType.startsWith('video/')), - ); - }); - }); - - describe('sidecar', () => { - it('should contain only lowercase mime types', () => { - const keys = Object.keys(mimeTypes.sidecar); - expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); - const values = Object.values(mimeTypes.sidecar); - expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); - }); - - it('should be a sorted list', () => { - const keys = Object.keys(mimeTypes.sidecar); - expect(keys).toEqual([...keys].sort()); - }); - }); - - describe('sidecar', () => { - it('should contain only be xml mime type', () => { - expect(Object.values(mimeTypes.sidecar)).toEqual( - Object.values(mimeTypes.sidecar).filter((mimeType) => mimeType === 'application/xml'), - ); - }); - }); - }); - describe('uploadFile', () => { it('should handle a file upload', async () => { const assetEntity = _getAsset_1();