diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 3d661f991..ec57c3591 100644 --- a/cli/src/api/open-api/api.ts +++ b/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} diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index 986ca1b53..8b0938636 100644 --- a/mobile/openapi/doc/AssetResponseDto.md +++ b/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] diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index cd74d5721..e979e4fc3 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/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 toJson() { final json = {}; @@ -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(json, r'isFavorite')!, isArchived: mapValueOfType(json, r'isArchived')!, - mimeType: mapValueOfType(json, r'mimeType'), duration: mapValueOfType(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', }; diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index 0bbcde257..c9e33fb3b 100644 --- a/mobile/openapi/test/asset_response_dto_test.dart +++ b/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 diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index b35660fda..8c700d669 100644 --- a/server/immich-openapi-specs.json +++ b/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" ] diff --git a/server/package-lock.json b/server/package-lock.json index f16a76e15..37f4bf0fa 100644 --- a/server/package-lock.json +++ b/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", diff --git a/server/package.json b/server/package.json index 9a8bd5298..9b5662c99 100644 --- a/server/package.json +++ b/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", diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index ed155c148..ef51c8831 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/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 () => { diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 10e1718c6..5a84a4a35 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/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 { diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 1df6b47e8..f5284f390 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/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, diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index fd04381a0..e61174747 100644 --- a/server/src/domain/domain.constant.ts +++ b/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 = { + '.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 = { + ...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 = { + '.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', +}; + +const isType = (filename: string, lookup: Record) => !!lookup[extname(filename).toLowerCase()]; +const getType = (filename: string, lookup: Record) => 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', +}; diff --git a/server/src/domain/facial-recognition/facial-recognition.service.spec.ts b/server/src/domain/facial-recognition/facial-recognition.service.spec.ts index 2d4de5637..2c737bf62 100644 --- a/server/src/domain/facial-recognition/facial-recognition.service.spec.ts +++ b/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, diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index 8a5f1e297..f67bc40c9 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/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 }); }); }); diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index 943da782d..98800d5fc 100644 --- a/server/src/domain/media/media.service.ts +++ b/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; } diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index 1931e0b9c..19cae7cfb 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/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', }); }); diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 3d786f9e2..d598f1293 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/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'] }, }); }); }); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index ed443f765..ce009f143 100644 --- a/server/src/domain/person/person.service.ts +++ b/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 { diff --git a/server/src/domain/storage-template/storage-template.service.spec.ts b/server/src/domain/storage-template/storage-template.service.spec.ts index 8c6a8ebc5..b34808e40 100644 --- a/server/src/domain/storage-template/storage-template.service.spec.ts +++ b/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, }, ], diff --git a/server/src/immich/api-v1/asset/asset.core.ts b/server/src/immich/api-v1/asset/asset.core.ts index c05d58dc0..7508e4f6c 100644 --- a/server/src/immich/api-v1/asset/asset.core.ts +++ b/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, 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 2135bf27a..f0115a34b 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/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; // 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 }, - ]; - - for (const { label, fieldName, mimeTypes } of tests) { - describe(`${label} mime types linting`, () => { - it('should be a sorted list', () => { - expect(mimeTypes).toEqual(mimeTypes.sort()); - }); - - it('should contain only unique values', () => { - expect(mimeTypes).toEqual([...new Set(mimeTypes)]); - }); - - 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/')), - ); - }); - } - + describe('mime types linting', () => { + describe('profile', () => { it('should contain only lowercase mime types', () => { - expect(mimeTypes).toEqual(mimeTypes.map((mimeType) => mimeType.toLowerCase())); + 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('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', () => { diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index fdf1c5a3b..5fe7df0d2 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/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, contentType?: string | null) { + private async streamFile(filepath: string, res: Res, headers: Record) { 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)); diff --git a/server/src/immich/app.interceptor.ts b/server/src/immich/app.interceptor.ts index 7a43ddefe..d6c4a3a7e 100644 --- a/server/src/immich/app.interceptor.ts +++ b/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'), }; diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index c070b5cd1..27d040bbb 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/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 diff --git a/server/src/infra/migrations/1689001889950-DropMimeTypeColumn.ts b/server/src/infra/migrations/1689001889950-DropMimeTypeColumn.ts new file mode 100644 index 000000000..45559313a --- /dev/null +++ b/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 { + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "mimeType"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ADD "mimeType" character varying`); + } + +} diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index 72a05a328..1c6f87e02 100644 --- a/server/test/fixtures.ts +++ b/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({ @@ -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({ @@ -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: [], }), }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 1292c7481..da6a8d174 100644 --- a/web/src/api/open-api/api.ts +++ b/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}