소스 검색

refactor(server): mime types (#3197)

* refactor(server): mime type check

* chore: open api

* chore: remove duplicate test
Jason Rasmussen 2 년 전
부모
커밋
6180828ed2

+ 0 - 6
cli/src/api/open-api/api.ts

@@ -679,12 +679,6 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      */
     'isArchived': boolean;
-    /**
-     * 
-     * @type {string}
-     * @memberof AssetResponseDto
-     */
-    'mimeType': string | null;
     /**
      * 
      * @type {string}

+ 0 - 1
mobile/openapi/doc/AssetResponseDto.md

@@ -22,7 +22,6 @@ Name | Type | Description | Notes
 **updatedAt** | [**DateTime**](DateTime.md) |  | 
 **isFavorite** | **bool** |  | 
 **isArchived** | **bool** |  | 
-**mimeType** | **String** |  | 
 **duration** | **String** |  | 
 **exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) |  | [optional] 
 **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) |  | [optional] 

+ 1 - 13
mobile/openapi/lib/model/asset_response_dto.dart

@@ -27,7 +27,6 @@ class AssetResponseDto {
     required this.updatedAt,
     required this.isFavorite,
     required this.isArchived,
-    required this.mimeType,
     required this.duration,
     this.exifInfo,
     this.smartInfo,
@@ -66,8 +65,6 @@ class AssetResponseDto {
 
   bool isArchived;
 
-  String? mimeType;
-
   String duration;
 
   ///
@@ -111,7 +108,6 @@ class AssetResponseDto {
      other.updatedAt == updatedAt &&
      other.isFavorite == isFavorite &&
      other.isArchived == isArchived &&
-     other.mimeType == mimeType &&
      other.duration == duration &&
      other.exifInfo == exifInfo &&
      other.smartInfo == smartInfo &&
@@ -137,7 +133,6 @@ class AssetResponseDto {
     (updatedAt.hashCode) +
     (isFavorite.hashCode) +
     (isArchived.hashCode) +
-    (mimeType == null ? 0 : mimeType!.hashCode) +
     (duration.hashCode) +
     (exifInfo == null ? 0 : exifInfo!.hashCode) +
     (smartInfo == null ? 0 : smartInfo!.hashCode) +
@@ -147,7 +142,7 @@ class AssetResponseDto {
     (checksum.hashCode);
 
   @override
-  String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, originalFileName=$originalFileName, resized=$resized, thumbhash=$thumbhash, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, updatedAt=$updatedAt, isFavorite=$isFavorite, isArchived=$isArchived, mimeType=$mimeType, duration=$duration, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags, people=$people, checksum=$checksum]';
+  String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, originalFileName=$originalFileName, resized=$resized, thumbhash=$thumbhash, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, updatedAt=$updatedAt, isFavorite=$isFavorite, isArchived=$isArchived, duration=$duration, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags, people=$people, checksum=$checksum]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -169,11 +164,6 @@ class AssetResponseDto {
       json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
       json[r'isFavorite'] = this.isFavorite;
       json[r'isArchived'] = this.isArchived;
-    if (this.mimeType != null) {
-      json[r'mimeType'] = this.mimeType;
-    } else {
-    //  json[r'mimeType'] = null;
-    }
       json[r'duration'] = this.duration;
     if (this.exifInfo != null) {
       json[r'exifInfo'] = this.exifInfo;
@@ -218,7 +208,6 @@ class AssetResponseDto {
         updatedAt: mapDateTime(json, r'updatedAt', r'')!,
         isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
         isArchived: mapValueOfType<bool>(json, r'isArchived')!,
-        mimeType: mapValueOfType<String>(json, r'mimeType'),
         duration: mapValueOfType<String>(json, r'duration')!,
         exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']),
         smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
@@ -287,7 +276,6 @@ class AssetResponseDto {
     'updatedAt',
     'isFavorite',
     'isArchived',
-    'mimeType',
     'duration',
     'checksum',
   };

+ 0 - 5
mobile/openapi/test/asset_response_dto_test.dart

@@ -87,11 +87,6 @@ void main() {
       // TODO
     });
 
-    // String mimeType
-    test('to test the property `mimeType`', () async {
-      // TODO
-    });
-
     // String duration
     test('to test the property `duration`', () async {
       // TODO

+ 0 - 5
server/immich-openapi-specs.json

@@ -4866,10 +4866,6 @@
           "isArchived": {
             "type": "boolean"
           },
-          "mimeType": {
-            "type": "string",
-            "nullable": true
-          },
           "duration": {
             "type": "string"
           },
@@ -4915,7 +4911,6 @@
           "updatedAt",
           "isFavorite",
           "isArchived",
-          "mimeType",
           "duration",
           "checksum"
         ]

+ 5 - 21
server/package-lock.json

@@ -21,7 +21,6 @@
         "@nestjs/typeorm": "^9.0.1",
         "@nestjs/websockets": "^9.2.1",
         "@socket.io/redis-adapter": "^8.0.1",
-        "@types/mime-types": "^2.1.1",
         "archiver": "^5.3.1",
         "axios": "^0.26.0",
         "bcrypt": "^5.0.1",
@@ -40,7 +39,6 @@
         "local-reverse-geocoder": "0.12.5",
         "lodash": "^4.17.21",
         "luxon": "^3.0.3",
-        "mime-types": "^2.1.35",
         "mv": "^2.1.1",
         "nest-commander": "^3.3.0",
         "openid-client": "^5.2.1",
@@ -55,8 +53,8 @@
         "ua-parser-js": "^1.0.35"
       },
       "bin": {
-        "immich": "./bin/cli.sh",
-        "immich-admin": "./bin/admin-cli.sh"
+        "immich": "bin/cli.sh",
+        "immich-admin": "bin/admin-cli.sh"
       },
       "devDependencies": {
         "@nestjs/cli": "^9.1.8",
@@ -3022,11 +3020,6 @@
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
       "dev": true
     },
-    "node_modules/@types/mime-types": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
-      "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw=="
-    },
     "node_modules/@types/multer": {
       "version": "1.4.7",
       "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
@@ -9079,7 +9072,6 @@
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
       "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
-      "dev": true,
       "dependencies": {
         "yocto-queue": "^0.1.0"
       },
@@ -9327,7 +9319,7 @@
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
       "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
-      "dev": true,
+      "devOptional": true,
       "engines": {
         "node": ">=8.6"
       },
@@ -12213,7 +12205,6 @@
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
       "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
-      "dev": true,
       "engines": {
         "node": ">=10"
       },
@@ -14458,11 +14449,6 @@
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
       "dev": true
     },
-    "@types/mime-types": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
-      "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw=="
-    },
     "@types/multer": {
       "version": "1.4.7",
       "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
@@ -19088,7 +19074,6 @@
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
       "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
-      "dev": true,
       "requires": {
         "yocto-queue": "^0.1.0"
       }
@@ -19271,7 +19256,7 @@
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
       "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
-      "dev": true
+      "devOptional": true
     },
     "pirates": {
       "version": "4.0.5",
@@ -21284,8 +21269,7 @@
     "yocto-queue": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
-      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
-      "dev": true
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
     },
     "zip-stream": {
       "version": "4.1.0",

+ 2 - 4
server/package.json

@@ -51,7 +51,6 @@
     "@nestjs/typeorm": "^9.0.1",
     "@nestjs/websockets": "^9.2.1",
     "@socket.io/redis-adapter": "^8.0.1",
-    "@types/mime-types": "^2.1.1",
     "archiver": "^5.3.1",
     "axios": "^0.26.0",
     "bcrypt": "^5.0.1",
@@ -64,12 +63,12 @@
     "fluent-ffmpeg": "^2.1.2",
     "handlebars": "^4.7.7",
     "i18n-iso-countries": "^7.5.0",
+    "immich": "^0.39.0",
     "ioredis": "^5.3.1",
     "joi": "^17.5.0",
     "local-reverse-geocoder": "0.12.5",
     "lodash": "^4.17.21",
     "luxon": "^3.0.3",
-    "mime-types": "^2.1.35",
     "mv": "^2.1.1",
     "nest-commander": "^3.3.0",
     "openid-client": "^5.2.1",
@@ -81,8 +80,7 @@
     "thumbhash": "^0.1.1",
     "typeorm": "^0.3.11",
     "typesense": "^1.5.3",
-    "ua-parser-js": "^1.0.35",
-    "immich": "^0.39.0"
+    "ua-parser-js": "^1.0.35"
   },
   "devDependencies": {
     "@nestjs/cli": "^9.1.8",

+ 1 - 4
server/src/domain/asset/asset.service.spec.ts

@@ -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 () => {

+ 2 - 2
server/src/domain/asset/asset.service.ts

@@ -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> {

+ 0 - 3
server/src/domain/asset/response-dto/asset-response.dto.ts

@@ -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,

+ 76 - 89
server/src/domain/domain.constant.ts

@@ -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',
+};

+ 3 - 3
server/src/domain/facial-recognition/facial-recognition.service.spec.ts

@@ -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,

+ 5 - 5
server/src/domain/media/media.service.spec.ts

@@ -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 });
     });
   });

+ 2 - 2
server/src/domain/media/media.service.ts

@@ -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;
   }

+ 2 - 2
server/src/domain/metadata/metadata.service.spec.ts

@@ -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',
       });
     });
 

+ 3 - 3
server/src/domain/person/person.service.spec.ts

@@ -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'] },
       });
     });
   });

+ 2 - 1
server/src/domain/person/person.service.ts

@@ -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[]> {

+ 17 - 17
server/src/domain/storage-template/storage-template.service.spec.ts

@@ -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,
           },
         ],

+ 0 - 1
server/src/immich/api-v1/asset/asset.core.ts

@@ -17,7 +17,6 @@ export class AssetCore {
     const asset = await this.repository.create({
       owner: { id: authUser.id } as UserEntity,
 
-      mimeType: file.mimeType,
       checksum: file.checksum,
       originalPath: file.originalPath,
 

+ 110 - 57
server/src/immich/api-v1/asset/asset.service.spec.ts

@@ -1,12 +1,9 @@
 import {
-  ASSET_MIME_TYPES,
   ICryptoRepository,
   IJobRepository,
   IStorageRepository,
   JobName,
-  LIVE_PHOTO_MIME_TYPES,
-  PROFILE_MIME_TYPES,
-  SIDECAR_MIME_TYPES,
+  mimeTypes,
   UploadFieldName,
 } from '@app/domain';
 import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
@@ -60,7 +57,6 @@ const _getAsset_1 = () => {
   asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z');
   asset_1.isFavorite = false;
   asset_1.isArchived = false;
-  asset_1.mimeType = 'image/jpeg';
   asset_1.webpPath = '';
   asset_1.encodedVideoPath = '';
   asset_1.duration = '0:00:00.000000';
@@ -85,7 +81,6 @@ const _getAsset_2 = () => {
   asset_2.updatedAt = new Date('2022-06-19T23:41:36.910Z');
   asset_2.isFavorite = false;
   asset_2.isArchived = false;
-  asset_2.mimeType = 'image/jpeg';
   asset_2.webpPath = '';
   asset_2.encodedVideoPath = '';
   asset_2.duration = '0:00:00.000000';
@@ -132,24 +127,11 @@ const uploadFile = {
     authUser: null,
     fieldName: UploadFieldName.ASSET_DATA,
     file: {
-      mimeType: 'image/jpeg',
       checksum: Buffer.from('checksum', 'utf8'),
       originalPath: 'upload/admin/image.jpeg',
       originalName: 'image.jpeg',
     },
   },
-  mimeType: (fieldName: UploadFieldName, mimeType: string) => {
-    return {
-      authUser: authStub.admin,
-      fieldName,
-      file: {
-        mimeType,
-        checksum: Buffer.from('checksum', 'utf8'),
-        originalPath: 'upload/admin/image.jpeg',
-        originalName: 'image.jpeg',
-      },
-    };
-  },
   filename: (fieldName: UploadFieldName, filename: string) => {
     return {
       authUser: authStub.admin,
@@ -164,6 +146,33 @@ const uploadFile = {
   },
 };
 
+const uploadTests = [
+  {
+    label: 'asset',
+    fieldName: UploadFieldName.ASSET_DATA,
+    filetypes: Object.keys({ ...mimeTypes.image, ...mimeTypes.video }),
+    invalid: ['.xml', '.html'],
+  },
+  {
+    label: 'live photo',
+    fieldName: UploadFieldName.LIVE_PHOTO_DATA,
+    filetypes: Object.keys(mimeTypes.video),
+    invalid: ['.xml', '.html', '.jpg', '.jpeg'],
+  },
+  {
+    label: 'sidecar',
+    fieldName: UploadFieldName.SIDECAR_DATA,
+    filetypes: Object.keys(mimeTypes.sidecar),
+    invalid: ['.xml', '.html', '.jpg', '.jpeg', '.mov', '.mp4'],
+  },
+  {
+    label: 'profile',
+    fieldName: UploadFieldName.PROFILE_DATA,
+    filetypes: Object.keys(mimeTypes.profile),
+    invalid: ['.xml', '.html', '.cr2', '.arf', '.mov', '.mp4'],
+  },
+];
+
 describe('AssetService', () => {
   let sut: AssetService;
   let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
@@ -195,8 +204,6 @@ describe('AssetService', () => {
       getByOriginalPath: jest.fn(),
     };
 
-    cryptoMock = newCryptoRepositoryMock();
-
     accessMock = newAccessRepositoryMock();
     cryptoMock = newCryptoRepositoryMock();
     jobMock = newJobRepositoryMock();
@@ -212,60 +219,106 @@ describe('AssetService', () => {
       .mockResolvedValue(assetEntityStub.livePhotoMotionAsset);
   });
 
-  const tests = [
-    { label: 'asset', fieldName: UploadFieldName.ASSET_DATA, mimeTypes: ASSET_MIME_TYPES },
-    { label: 'live photo', fieldName: UploadFieldName.LIVE_PHOTO_DATA, mimeTypes: LIVE_PHOTO_MIME_TYPES },
-    { label: 'sidecar', fieldName: UploadFieldName.SIDECAR_DATA, mimeTypes: SIDECAR_MIME_TYPES },
-    { label: 'profile', fieldName: UploadFieldName.PROFILE_DATA, mimeTypes: PROFILE_MIME_TYPES },
-  ];
+  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()));
+      });
 
-  for (const { label, fieldName, mimeTypes } of tests) {
-    describe(`${label} mime types linting`, () => {
       it('should be a sorted list', () => {
-        expect(mimeTypes).toEqual(mimeTypes.sort());
+        const keys = Object.keys(mimeTypes.profile);
+        expect(keys).toEqual([...keys].sort());
       });
+    });
 
-      it('should contain only unique values', () => {
-        expect(mimeTypes).toEqual([...new Set(mimeTypes)]);
+    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()));
       });
 
-      if (fieldName !== UploadFieldName.SIDECAR_DATA) {
-        it('should contain only image or video mime types', () => {
-          expect(mimeTypes).toEqual(
-            mimeTypes.filter((mimeType) => mimeType.startsWith('image/') || mimeType.startsWith('video/')),
-          );
-        });
-      }
+      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', () => {
-        expect(mimeTypes).toEqual(mimeTypes.map((mimeType) => mimeType.toLowerCase()));
+        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('canUpload', () => {
     it('should require an authenticated user', () => {
       expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
     });
 
-    it('should accept all accepted mime types', () => {
-      for (const { fieldName, mimeTypes } of tests) {
-        for (const mimeType of mimeTypes) {
-          expect(sut.canUploadFile(uploadFile.mimeType(fieldName, mimeType))).toEqual(true);
+    for (const { fieldName, filetypes, invalid } of uploadTests) {
+      describe(`${fieldName}`, () => {
+        for (const filetype of filetypes) {
+          it(`should accept ${filetype}`, () => {
+            expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
+          });
         }
-      }
-    });
 
-    it('should reject other mime types', () => {
-      for (const { fieldName, mimeType } of [
-        { fieldName: UploadFieldName.ASSET_DATA, mimeType: 'application/html' },
-        { fieldName: UploadFieldName.LIVE_PHOTO_DATA, mimeType: 'application/html' },
-        { fieldName: UploadFieldName.PROFILE_DATA, mimeType: 'application/html' },
-        { fieldName: UploadFieldName.SIDECAR_DATA, mimeType: 'image/jpeg' },
-      ]) {
-        expect(() => sut.canUploadFile(uploadFile.mimeType(fieldName, mimeType))).toThrowError(BadRequestException);
-      }
-    });
+        for (const filetype of invalid) {
+          it(`should reject ${filetype}`, () => {
+            expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
+              BadRequestException,
+            );
+          });
+        }
+      });
+    }
   });
 
   describe('getUploadFilename', () => {

+ 29 - 46
server/src/immich/api-v1/asset/asset.service.ts

@@ -1,7 +1,6 @@
 import {
   AccessCore,
   AssetResponseDto,
-  ASSET_MIME_TYPES,
   AuthUserDto,
   getLivePhotoMotionFilename,
   IAccessRepository,
@@ -9,12 +8,10 @@ import {
   IJobRepository,
   IStorageRepository,
   JobName,
-  LIVE_PHOTO_MIME_TYPES,
   mapAsset,
   mapAssetWithoutExif,
+  mimeTypes,
   Permission,
-  PROFILE_MIME_TYPES,
-  SIDECAR_MIME_TYPES,
   StorageCore,
   StorageFolder,
   UploadFieldName,
@@ -33,7 +30,6 @@ import { InjectRepository } from '@nestjs/typeorm';
 import { Response as Res } from 'express';
 import { constants, createReadStream } from 'fs';
 import fs from 'fs/promises';
-import mime from 'mime-types';
 import path, { extname } from 'path';
 import sanitize from 'sanitize-filename';
 import { pipeline } from 'stream/promises';
@@ -71,11 +67,6 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
 import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
 
-interface ServableFile {
-  filepath: string;
-  contentType: string;
-}
-
 @Injectable()
 export class AssetService {
   readonly logger = new Logger(AssetService.name);
@@ -98,35 +89,36 @@ export class AssetService {
   canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
     this.access.requireUploadAccess(authUser);
 
+    const filename = file.originalName;
+
     switch (fieldName) {
       case UploadFieldName.ASSET_DATA:
-        if (ASSET_MIME_TYPES.includes(file.mimeType)) {
+        if (mimeTypes.isAsset(filename)) {
           return true;
         }
         break;
 
       case UploadFieldName.LIVE_PHOTO_DATA:
-        if (LIVE_PHOTO_MIME_TYPES.includes(file.mimeType)) {
+        if (mimeTypes.isVideo(filename)) {
           return true;
         }
         break;
 
       case UploadFieldName.SIDECAR_DATA:
-        if (SIDECAR_MIME_TYPES.includes(file.mimeType)) {
+        if (mimeTypes.isSidecar(filename)) {
           return true;
         }
         break;
 
       case UploadFieldName.PROFILE_DATA:
-        if (PROFILE_MIME_TYPES.includes(file.mimeType)) {
+        if (mimeTypes.isProfile(filename)) {
           return true;
         }
         break;
     }
 
-    const ext = extname(file.originalName);
-    this.logger.error(`Unsupported file type ${ext} file MIME type ${file.mimeType}`);
-    throw new BadRequestException(`Unsupported file type ${ext}`);
+    this.logger.error(`Unsupported file type ${filename}`);
+    throw new BadRequestException(`Unsupported file type ${filename}`);
   }
 
   getUploadFilename({ authUser, fieldName, file }: UploadRequest): string {
@@ -208,15 +200,12 @@ export class AssetService {
       sidecarPath: dto.sidecarPath ? path.resolve(dto.sidecarPath) : undefined,
     };
 
-    const mimeType = mime.lookup(dto.assetPath) as string;
-    if (!ASSET_MIME_TYPES.includes(mimeType)) {
-      throw new BadRequestException(`Unsupported file type ${mimeType}`);
+    if (!mimeTypes.isAsset(dto.assetPath)) {
+      throw new BadRequestException(`Unsupported file type ${dto.assetPath}`);
     }
 
-    if (dto.sidecarPath) {
-      if (path.extname(dto.sidecarPath).toLowerCase() !== '.xmp') {
-        throw new BadRequestException(`Unsupported sidecar file type`);
-      }
+    if (dto.sidecarPath && !mimeTypes.isSidecar(dto.sidecarPath)) {
+      throw new BadRequestException(`Unsupported sidecar file type`);
     }
 
     for (const filepath of [dto.assetPath, dto.sidecarPath]) {
@@ -236,7 +225,6 @@ export class AssetService {
 
     const assetFile: UploadFile = {
       checksum: await this.cryptoRepository.hashFile(dto.assetPath),
-      mimeType,
       originalPath: dto.assetPath,
       originalName: path.parse(dto.assetPath).name,
     };
@@ -328,8 +316,7 @@ export class AssetService {
     }
 
     try {
-      const [thumbnailPath, contentType] = this.getThumbnailPath(asset, query.format);
-      return this.streamFile(thumbnailPath, res, headers, contentType);
+      return this.streamFile(this.getThumbnailPath(asset, query.format), res, headers);
     } catch (e) {
       res.header('Cache-Control', 'none');
       this.logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail');
@@ -360,8 +347,7 @@ export class AssetService {
     // Handle Sending Images
     if (asset.type == AssetType.IMAGE) {
       try {
-        const { filepath, contentType } = this.getServePath(asset, query, allowOriginalFile);
-        return this.streamFile(filepath, res, headers, contentType);
+        return this.streamFile(this.getServePath(asset, query, allowOriginalFile), res, headers);
       } catch (e) {
         this.logger.error(`Cannot create read stream for asset ${asset.id} ${JSON.stringify(e)}`, 'serveFile[IMAGE]');
         throw new InternalServerErrorException(
@@ -371,10 +357,7 @@ export class AssetService {
       }
     } else {
       try {
-        const videoPath = asset.encodedVideoPath ? asset.encodedVideoPath : asset.originalPath;
-        const mimeType = asset.encodedVideoPath ? 'video/mp4' : asset.mimeType;
-
-        return this.streamFile(videoPath, res, headers, mimeType);
+        return this.streamFile(asset.encodedVideoPath || asset.originalPath, res, headers);
       } catch (e: Error | any) {
         this.logger.error(`Error serving VIDEO asset=${asset.id}`, e?.stack);
         throw new InternalServerErrorException(`Failed to serve video asset ${e}`, 'ServeFile');
@@ -595,7 +578,7 @@ export class AssetService {
     switch (format) {
       case GetAssetThumbnailFormatEnum.WEBP:
         if (asset.webpPath) {
-          return [asset.webpPath, 'image/webp'];
+          return asset.webpPath;
         }
         this.logger.warn(`WebP thumbnail requested but not found for asset ${asset.id}, falling back to JPEG`);
 
@@ -604,48 +587,48 @@ export class AssetService {
         if (!asset.resizePath) {
           throw new NotFoundException(`No thumbnail found for asset ${asset.id}`);
         }
-        return [asset.resizePath, 'image/jpeg'];
+        return asset.resizePath;
     }
   }
 
-  private getServePath(asset: AssetEntity, query: ServeFileDto, allowOriginalFile: boolean): ServableFile {
+  private getServePath(asset: AssetEntity, query: ServeFileDto, allowOriginalFile: boolean): string {
+    const mimeType = mimeTypes.lookup(asset.originalPath);
+
     /**
      * Serve file viewer on the web
      */
-    if (query.isWeb && asset.mimeType != 'image/gif') {
+    if (query.isWeb && mimeType != 'image/gif') {
       if (!asset.resizePath) {
         this.logger.error('Error serving IMAGE asset for web');
         throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
       }
 
-      return { filepath: asset.resizePath, contentType: 'image/jpeg' };
+      return asset.resizePath;
     }
 
     /**
      * Serve thumbnail image for both web and mobile app
      */
-    if ((!query.isThumb && allowOriginalFile) || (query.isWeb && asset.mimeType === 'image/gif')) {
-      return { filepath: asset.originalPath, contentType: asset.mimeType as string };
+    if ((!query.isThumb && allowOriginalFile) || (query.isWeb && mimeType === 'image/gif')) {
+      return asset.originalPath;
     }
 
     if (asset.webpPath && asset.webpPath.length > 0) {
-      return { filepath: asset.webpPath, contentType: 'image/webp' };
+      return asset.webpPath;
     }
 
     if (!asset.resizePath) {
       throw new Error('resizePath not set');
     }
 
-    return { filepath: asset.resizePath, contentType: 'image/jpeg' };
+    return asset.resizePath;
   }
 
-  private async streamFile(filepath: string, res: Res, headers: Record<string, string>, contentType?: string | null) {
+  private async streamFile(filepath: string, res: Res, headers: Record<string, string>) {
     await fs.access(filepath, constants.R_OK);
     const { size, mtimeNs } = await fs.stat(filepath, { bigint: true });
 
-    if (contentType) {
-      res.header('Content-Type', contentType);
-    }
+    res.header('Content-Type', mimeTypes.lookup(filepath));
 
     const range = this.setResRange(res, headers, Number(size));
 

+ 0 - 1
server/src/immich/app.interceptor.ts

@@ -23,7 +23,6 @@ export interface ImmichFile extends Express.Multer.File {
 export function mapToUploadFile(file: ImmichFile): UploadFile {
   return {
     checksum: file.checksum,
-    mimeType: file.mimetype,
     originalPath: file.path,
     originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'),
   };

+ 0 - 3
server/src/infra/entities/asset.entity.ts

@@ -78,9 +78,6 @@ export class AssetEntity {
   @Column({ type: 'boolean', default: false })
   isReadOnly!: boolean;
 
-  @Column({ type: 'varchar', nullable: true })
-  mimeType!: string | null;
-
   @Column({ type: 'bytea' })
   @Index()
   checksum!: Buffer; // sha1 checksum

+ 14 - 0
server/src/infra/migrations/1689001889950-DropMimeTypeColumn.ts

@@ -0,0 +1,14 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class DropMimeTypeColumn1689001889950 implements MigrationInterface {
+    name = 'DropMimeTypeColumn1689001889950'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "mimeType"`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "assets" ADD "mimeType" character varying`);
+    }
+
+}

+ 6 - 17
server/test/fixtures.ts

@@ -190,13 +190,11 @@ export const userEntityStub = {
 export const fileStub = {
   livePhotoStill: Object.freeze({
     originalPath: 'fake_path/asset_1.jpeg',
-    mimeType: 'image/jpg',
     checksum: Buffer.from('file hash', 'utf8'),
     originalName: 'asset_1.jpeg',
   }),
   livePhotoMotion: Object.freeze({
     originalPath: 'fake_path/asset_1.mp4',
-    mimeType: 'image/jpeg',
     checksum: Buffer.from('live photo file hash', 'utf8'),
     originalName: 'asset_1.mp4',
   }),
@@ -221,7 +219,6 @@ export const assetEntityStub = {
     encodedVideoPath: null,
     createdAt: new Date('2023-02-23T05:06:29.716Z'),
     updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    mimeType: null,
     isFavorite: true,
     isArchived: false,
     duration: null,
@@ -251,7 +248,6 @@ export const assetEntityStub = {
     encodedVideoPath: null,
     createdAt: new Date('2023-02-23T05:06:29.716Z'),
     updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    mimeType: null,
     isFavorite: true,
     isArchived: false,
     duration: null,
@@ -285,7 +281,6 @@ export const assetEntityStub = {
     encodedVideoPath: null,
     createdAt: new Date('2023-02-23T05:06:29.716Z'),
     updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    mimeType: null,
     isFavorite: true,
     isArchived: false,
     isReadOnly: false,
@@ -307,8 +302,8 @@ export const assetEntityStub = {
     owner: userEntityStub.user1,
     ownerId: 'user-id',
     deviceId: 'device-id',
-    originalPath: '/original/path.ext',
-    resizePath: '/uploads/user-id/thumbs/path.ext',
+    originalPath: '/original/path.jpg',
+    resizePath: '/uploads/user-id/thumbs/path.jpg',
     checksum: Buffer.from('file hash', 'utf8'),
     type: AssetType.IMAGE,
     webpPath: '/uploads/user-id/webp/path.ext',
@@ -316,7 +311,6 @@ export const assetEntityStub = {
     encodedVideoPath: null,
     createdAt: new Date('2023-02-23T05:06:29.716Z'),
     updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    mimeType: null,
     isFavorite: true,
     isArchived: false,
     isReadOnly: false,
@@ -326,7 +320,7 @@ export const assetEntityStub = {
     livePhotoVideoId: null,
     tags: [],
     sharedLinks: [],
-    originalFileName: 'asset-id.ext',
+    originalFileName: 'asset-id.jpg',
     faces: [],
     sidecarPath: null,
     exifInfo: {
@@ -351,7 +345,6 @@ export const assetEntityStub = {
     encodedVideoPath: null,
     createdAt: new Date('2023-02-23T05:06:29.716Z'),
     updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    mimeType: null,
     isFavorite: true,
     isArchived: false,
     isReadOnly: false,
@@ -412,7 +405,6 @@ export const assetEntityStub = {
     encodedVideoPath: null,
     createdAt: new Date('2023-02-23T05:06:29.716Z'),
     updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    mimeType: null,
     isFavorite: false,
     isArchived: false,
     isReadOnly: false,
@@ -447,7 +439,6 @@ export const assetEntityStub = {
     encodedVideoPath: null,
     createdAt: new Date('2023-02-23T05:06:29.716Z'),
     updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    mimeType: null,
     isFavorite: true,
     isArchived: false,
     isReadOnly: false,
@@ -621,7 +612,6 @@ const assetResponse: AssetResponseDto = {
   updatedAt: today,
   isFavorite: false,
   isArchived: false,
-  mimeType: 'image/jpeg',
   smartInfo: {
     tags: [],
     objects: ['a', 'b', 'c'],
@@ -909,7 +899,6 @@ export const sharedLinkStub = {
           isFavorite: false,
           isArchived: false,
           isReadOnly: false,
-          mimeType: 'image/jpeg',
           smartInfo: {
             assetId: 'id_1',
             tags: [],
@@ -1136,7 +1125,7 @@ export const personStub = {
     ownerId: userEntityStub.admin.id,
     owner: userEntityStub.admin,
     name: '',
-    thumbnailPath: '/path/to/thumbnail',
+    thumbnailPath: '/path/to/thumbnail.jpg',
     faces: [],
   }),
   withName: Object.freeze<PersonEntity>({
@@ -1146,7 +1135,7 @@ export const personStub = {
     ownerId: userEntityStub.admin.id,
     owner: userEntityStub.admin,
     name: 'Person 1',
-    thumbnailPath: '/path/to/thumbnail',
+    thumbnailPath: '/path/to/thumbnail.jpg',
     faces: [],
   }),
   noThumbnail: Object.freeze<PersonEntity>({
@@ -1166,7 +1155,7 @@ export const personStub = {
     ownerId: userEntityStub.admin.id,
     owner: userEntityStub.admin,
     name: '',
-    thumbnailPath: '/new/path/to/thumbnail',
+    thumbnailPath: '/new/path/to/thumbnail.jpg',
     faces: [],
   }),
 };

+ 0 - 6
web/src/api/open-api/api.ts

@@ -679,12 +679,6 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      */
     'isArchived': boolean;
-    /**
-     * 
-     * @type {string}
-     * @memberof AssetResponseDto
-     */
-    'mimeType': string | null;
     /**
      * 
      * @type {string}