Pārlūkot izejas kodu

refactor(server): calculate asset type server-side

Jason Rasmussen 2 gadi atpakaļ
vecāks
revīzija
120727889f

+ 0 - 3
cli/src/commands/upload.ts

@@ -71,7 +71,6 @@ export default class Upload extends BaseCommand {
           const importData = {
             assetPath: asset.path,
             deviceAssetId: asset.deviceAssetId,
-            assetType: asset.assetType,
             deviceId: this.deviceId,
             fileCreatedAt: asset.fileCreatedAt,
             fileModifiedAt: asset.fileModifiedAt,
@@ -157,8 +156,6 @@ export default class Upload extends BaseCommand {
       uploadFormData.append('fileCreatedAt', asset.fileCreatedAt);
       uploadFormData.append('fileModifiedAt', asset.fileModifiedAt);
       uploadFormData.append('isFavorite', String(false));
-      uploadFormData.append('fileExtension', asset.fileExtension);
-      uploadFormData.append('assetType', asset.assetType);
       uploadFormData.append('assetData', asset.assetData, { filename: asset.path });
 
       if (asset.sidecarData) {

+ 0 - 13
cli/src/cores/models/crawled-asset.ts

@@ -1,19 +1,14 @@
 import * as fs from 'fs';
-import * as mime from 'mime-types';
 import { basename } from 'node:path';
-import * as path from 'path';
 import crypto from 'crypto';
-import { AssetTypeEnum } from 'src/api/open-api';
 
 export class CrawledAsset {
   public path: string;
 
-  public assetType?: AssetTypeEnum;
   public assetData?: fs.ReadStream;
   public deviceAssetId?: string;
   public fileCreatedAt?: string;
   public fileModifiedAt?: string;
-  public fileExtension?: string;
   public sidecarData?: Buffer;
   public sidecarPath?: string;
   public fileSize!: number;
@@ -30,16 +25,8 @@ export class CrawledAsset {
   async process() {
     const stats = await fs.promises.stat(this.path);
     this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replace(/\s+/g, '');
-
-    // TODO: Determine file type from extension only
-    const mimeType = mime.lookup(this.path);
-    if (!mimeType) {
-      throw Error('Cannot determine mime type of asset: ' + this.path);
-    }
-    this.assetType = mimeType.split('/')[0].toUpperCase() as AssetTypeEnum;
     this.fileCreatedAt = stats.ctime.toISOString();
     this.fileModifiedAt = stats.mtime.toISOString();
-    this.fileExtension = path.extname(this.path);
     this.fileSize = stats.size;
 
     // TODO: doesn't xmp replace the file extension? Will need investigation

+ 0 - 1
cli/src/services/upload.service.spec.ts

@@ -21,7 +21,6 @@ describe('UploadService', () => {
 
   it('should upload a single file', async () => {
     const data = new FormData();
-    data.append('assetType', 'image');
 
     uploadService.upload(data);
 

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

@@ -5069,9 +5069,6 @@
       "CreateAssetDto": {
         "type": "object",
         "properties": {
-          "assetType": {
-            "$ref": "#/components/schemas/AssetTypeEnum"
-          },
           "assetData": {
             "type": "string",
             "format": "binary"
@@ -5088,9 +5085,6 @@
             "type": "boolean",
             "default": false
           },
-          "fileExtension": {
-            "type": "string"
-          },
           "deviceAssetId": {
             "type": "string"
           },
@@ -5119,9 +5113,7 @@
           }
         },
         "required": [
-          "assetType",
           "assetData",
-          "fileExtension",
           "deviceAssetId",
           "deviceId",
           "fileCreatedAt",
@@ -5492,9 +5484,6 @@
       "ImportAssetDto": {
         "type": "object",
         "properties": {
-          "assetType": {
-            "$ref": "#/components/schemas/AssetTypeEnum"
-          },
           "isReadOnly": {
             "type": "boolean",
             "default": true
@@ -5533,7 +5522,6 @@
           }
         },
         "required": [
-          "assetType",
           "assetPath",
           "deviceAssetId",
           "deviceId",

+ 15 - 1
server/src/domain/domain.constant.ts

@@ -1,3 +1,4 @@
+import { AssetType } from '@app/infra/entities';
 import { BadRequestException } from '@nestjs/common';
 import { extname } from 'node:path';
 import pkg from 'src/../../package.json';
@@ -91,6 +92,8 @@ const sidecar: Record<string, string> = {
 
 const isType = (filename: string, lookup: Record<string, string>) => !!lookup[extname(filename).toLowerCase()];
 const getType = (filename: string, lookup: Record<string, string>) => lookup[extname(filename).toLowerCase()];
+const lookup = (filename: string) =>
+  getType(filename, { ...image, ...video, ...sidecar }) || 'application/octet-stream';
 
 export const mimeTypes = {
   image,
@@ -102,5 +105,16 @@ export const mimeTypes = {
   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',
+  lookup,
+  assetType: (filename: string) => {
+    const contentType = lookup(filename).split('/')[0];
+    switch (contentType) {
+      case 'image':
+        return AssetType.IMAGE;
+      case 'video':
+        return AssetType.VIDEO;
+      default:
+        return AssetType.OTHER;
+    }
+  },
 };

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

@@ -1,4 +1,4 @@
-import { AuthUserDto, IJobRepository, JobName, UploadFile } from '@app/domain';
+import { AuthUserDto, IJobRepository, JobName, mimeTypes, UploadFile } from '@app/domain';
 import { AssetEntity, UserEntity } from '@app/infra/entities';
 import { parse } from 'node:path';
 import { IAssetRepository } from './asset-repository';
@@ -26,7 +26,7 @@ export class AssetCore {
       fileCreatedAt: dto.fileCreatedAt,
       fileModifiedAt: dto.fileModifiedAt,
 
-      type: dto.assetType,
+      type: mimeTypes.assetType(file.originalPath),
       isFavorite: dto.isFavorite,
       isArchived: dto.isArchived ?? false,
       duration: dto.duration || null,

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

@@ -32,7 +32,6 @@ const _getCreateAssetDto = (): CreateAssetDto => {
   const createAssetDto = new CreateAssetDto();
   createAssetDto.deviceAssetId = 'deviceAssetId';
   createAssetDto.deviceId = 'deviceId';
-  createAssetDto.assetType = AssetType.OTHER;
   createAssetDto.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
   createAssetDto.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
   createAssetDto.isFavorite = false;

+ 1 - 10
server/src/immich/api-v1/asset/dto/create-asset.dto.ts

@@ -1,8 +1,7 @@
 import { toBoolean, toSanitized, UploadFieldName } from '@app/domain';
-import { AssetType } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import { Transform } from 'class-transformer';
-import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
 
 export class CreateAssetBase {
   @IsNotEmpty()
@@ -11,11 +10,6 @@ export class CreateAssetBase {
   @IsNotEmpty()
   deviceId!: string;
 
-  @IsNotEmpty()
-  @IsEnum(AssetType)
-  @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
-  assetType!: AssetType;
-
   @IsNotEmpty()
   fileCreatedAt!: Date;
 
@@ -43,9 +37,6 @@ export class CreateAssetDto extends CreateAssetBase {
   @Transform(toBoolean)
   isReadOnly?: boolean = false;
 
-  @IsNotEmpty()
-  fileExtension!: string;
-
   // The properties below are added to correctly generate the API docs
   // and client SDKs. Validation should be handled in the controller.
   @ApiProperty({ type: 'string', format: 'binary' })

+ 2 - 1
web/src/lib/components/shared-components/upload-asset-preview.svelte

@@ -4,6 +4,7 @@
   import { asByteUnitString } from '$lib/utils/byte-units';
   import { fade } from 'svelte/transition';
   import ImmichLogo from './immich-logo.svelte';
+  import { getFilenameExtension } from '../../utils/asset-utils';
 
   export let uploadAsset: UploadAsset;
 
@@ -42,7 +43,7 @@
       <p
         class="absolute bottom-1 right-1 object-right-bottom text-gray-50/95 font-semibold stroke-immich-primary uppercase"
       >
-        .{uploadAsset.fileExtension}
+        .{getFilenameExtension(uploadAsset.file.name)}
       </p>
     </div>
   </div>

+ 0 - 1
web/src/lib/models/upload-asset.ts

@@ -2,5 +2,4 @@ export type UploadAsset = {
   id: string;
   file: File;
   progress: number;
-  fileExtension: string;
 };

+ 1 - 86
web/src/lib/utils/asset-utils.spec.ts

@@ -1,6 +1,6 @@
 import type { AssetResponseDto } from '@api';
 import { describe, expect, it } from '@jest/globals';
-import { getAssetFilename, getFileMimeType, getFilenameExtension } from './asset-utils';
+import { getAssetFilename, getFilenameExtension } from './asset-utils';
 
 describe('get file extension from filename', () => {
   it('returns the extension without including the dot', () => {
@@ -57,88 +57,3 @@ describe('get asset filename', () => {
     });
   });
 });
-
-describe('get file mime type', () => {
-  for (const { mimetype, extension } of [
-    { mimetype: 'image/avif', extension: 'avif' },
-    { mimetype: 'image/gif', extension: 'gif' },
-    { mimetype: 'image/heic', extension: 'heic' },
-    { mimetype: 'image/heif', extension: 'heif' },
-    { mimetype: 'image/jpeg', extension: 'jpeg' },
-    { mimetype: 'image/jpeg', extension: 'jpg' },
-    { mimetype: 'image/jxl', extension: 'jxl' },
-    { mimetype: 'image/png', extension: 'png' },
-    { mimetype: 'image/tiff', extension: 'tiff' },
-    { mimetype: 'image/webp', extension: 'webp' },
-    { mimetype: 'image/x-adobe-dng', extension: 'dng' },
-    { mimetype: 'image/x-arriflex-ari', extension: 'ari' },
-    { mimetype: 'image/x-canon-cr2', extension: 'cr2' },
-    { mimetype: 'image/x-canon-cr3', extension: 'cr3' },
-    { mimetype: 'image/x-canon-crw', extension: 'crw' },
-    { mimetype: 'image/x-epson-erf', extension: 'erf' },
-    { mimetype: 'image/x-fuji-raf', extension: 'raf' },
-    { mimetype: 'image/x-hasselblad-3fr', extension: '3fr' },
-    { mimetype: 'image/x-hasselblad-fff', extension: 'fff' },
-    { mimetype: 'image/x-kodak-dcr', extension: 'dcr' },
-    { mimetype: 'image/x-kodak-k25', extension: 'k25' },
-    { mimetype: 'image/x-kodak-kdc', extension: 'kdc' },
-    { mimetype: 'image/x-leica-rwl', extension: 'rwl' },
-    { mimetype: 'image/x-minolta-mrw', extension: 'mrw' },
-    { mimetype: 'image/x-nikon-nef', extension: 'nef' },
-    { mimetype: 'image/x-olympus-orf', extension: 'orf' },
-    { mimetype: 'image/x-olympus-ori', extension: 'ori' },
-    { mimetype: 'image/x-panasonic-raw', extension: 'raw' },
-    { mimetype: 'image/x-pentax-pef', extension: 'pef' },
-    { mimetype: 'image/x-phantom-cin', extension: 'cin' },
-    { mimetype: 'image/x-phaseone-cap', extension: 'cap' },
-    { mimetype: 'image/x-phaseone-iiq', extension: 'iiq' },
-    { mimetype: 'image/x-samsung-srw', extension: 'srw' },
-    { mimetype: 'image/x-sigma-x3f', extension: 'x3f' },
-    { mimetype: 'image/x-sony-arw', extension: 'arw' },
-    { mimetype: 'image/x-sony-sr2', extension: 'sr2' },
-    { mimetype: 'image/x-sony-srf', extension: 'srf' },
-    { mimetype: 'video/3gpp', extension: '3gp' },
-    { mimetype: 'video/avi', extension: 'avi' },
-    { mimetype: 'video/mp2t', extension: 'm2ts' },
-    { mimetype: 'video/mp2t', extension: 'mts' },
-    { mimetype: 'video/mp4', extension: 'mp4' },
-    { mimetype: 'video/mpeg', extension: 'mpg' },
-    { mimetype: 'video/quicktime', extension: 'mov' },
-    { mimetype: 'video/webm', extension: 'webm' },
-    { mimetype: 'video/x-flv', extension: 'flv' },
-    { mimetype: 'video/x-matroska', extension: 'mkv' },
-    { mimetype: 'video/x-ms-wmv', extension: 'wmv' },
-  ]) {
-    it(`returns the mime type for ${extension}`, () => {
-      expect(getFileMimeType({ name: `filename.${extension}` } as File)).toEqual(mimetype);
-    });
-  }
-
-  it('returns the mime type from the file', () => {
-    [
-      {
-        file: {
-          name: 'filename.jpg',
-          type: 'image/jpeg',
-        },
-        result: 'image/jpeg',
-      },
-      {
-        file: {
-          name: 'filename.txt',
-          type: 'text/plain',
-        },
-        result: 'text/plain',
-      },
-      {
-        file: {
-          name: 'filename.txt',
-          type: '',
-        },
-        result: '',
-      },
-    ].forEach(({ file, result }) => {
-      expect(getFileMimeType(file as File)).toEqual(result);
-    });
-  });
-});

+ 0 - 60
web/src/lib/utils/asset-utils.ts

@@ -136,66 +136,6 @@ export function getAssetFilename(asset: AssetResponseDto): string {
   return `${asset.originalFileName}.${fileExtension}`;
 }
 
-/**
- * Returns the MIME type of the file and an empty string when not found.
- */
-export function getFileMimeType(file: File): string {
-  const mimeTypes: Record<string, string> = {
-    '3fr': 'image/x-hasselblad-3fr',
-    '3gp': 'video/3gpp',
-    ari: 'image/x-arriflex-ari',
-    arw: 'image/x-sony-arw',
-    avi: 'video/avi',
-    avif: 'image/avif',
-    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',
-    dng: 'image/x-adobe-dng',
-    erf: 'image/x-epson-erf',
-    fff: 'image/x-hasselblad-fff',
-    flv: 'video/x-flv',
-    gif: 'image/gif',
-    heic: 'image/heic',
-    heif: 'image/heif',
-    iiq: 'image/x-phaseone-iiq',
-    insp: 'image/jpeg',
-    insv: 'video/mp4',
-    jpeg: 'image/jpeg',
-    jpg: 'image/jpeg',
-    jxl: 'image/jxl',
-    k25: 'image/x-kodak-k25',
-    kdc: 'image/x-kodak-kdc',
-    m2ts: 'video/mp2t',
-    mkv: 'video/x-matroska',
-    mov: 'video/quicktime',
-    mp4: 'video/mp4',
-    mpg: 'video/mpeg',
-    mrw: 'image/x-minolta-mrw',
-    mts: 'video/mp2t',
-    nef: 'image/x-nikon-nef',
-    orf: 'image/x-olympus-orf',
-    ori: 'image/x-olympus-ori',
-    pef: 'image/x-pentax-pef',
-    png: 'image/png',
-    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',
-    webm: 'video/webm',
-    webp: 'image/webp',
-    wmv: 'video/x-ms-wmv',
-    x3f: 'image/x-sigma-x3f',
-  };
-  // Return the MIME type determined by the browser or the MIME type based on the file extension.
-  return file.type || (mimeTypes[getFilenameExtension(file.name)] ?? '');
-}
-
 function isRotated90CW(orientation: number) {
   return orientation == 6 || orientation == 90;
 }

+ 53 - 62
web/src/lib/utils/file-uploader.ts

@@ -1,11 +1,60 @@
 import { uploadAssetsStore } from '$lib/stores/upload';
-import { addAssetsToAlbum, getFileMimeType, getFilenameExtension } from '$lib/utils/asset-utils';
+import { addAssetsToAlbum, getFilenameExtension } from '$lib/utils/asset-utils';
 import type { AssetFileUploadResponseDto } from '@api';
 import axios from 'axios';
 import { combineLatestAll, filter, firstValueFrom, from, mergeMap, of } from 'rxjs';
 import type { UploadAsset } from '../models/upload-asset';
 import { notificationController, NotificationType } from './../components/shared-components/notification/notification';
 
+const extensions = [
+  '.3fr',
+  '.3gp',
+  '.ari',
+  '.arw',
+  '.avi',
+  '.avif',
+  '.cap',
+  '.cin',
+  '.cr2',
+  '.cr3',
+  '.crw',
+  '.dcr',
+  '.dng',
+  '.erf',
+  '.fff',
+  '.flv',
+  '.gif',
+  '.heic',
+  '.heif',
+  '.iiq',
+  '.jpeg',
+  '.jpg',
+  '.k25',
+  '.kdc',
+  '.mkv',
+  '.mov',
+  '.mp2t',
+  '.mp4',
+  '.mpeg',
+  '.mrw',
+  '.nef',
+  '.orf',
+  '.ori',
+  '.pef',
+  '.png',
+  '.raf',
+  '.raw',
+  '.rwl',
+  '.sr2',
+  '.srf',
+  '.srw',
+  '.tiff',
+  '.webm',
+  '.webp',
+  '.wmv',
+  '.x3f',
+];
+
 export const openFileUploadDialog = async (
   albumId: string | undefined = undefined,
   sharedKey: string | undefined = undefined,
@@ -16,52 +65,7 @@ export const openFileUploadDialog = async (
 
       fileSelector.type = 'file';
       fileSelector.multiple = true;
-
-      // When adding a content type that is unsupported by browsers, make sure
-      // to also add it to getFileMimeType() otherwise the upload will fail.
-      fileSelector.accept = [
-        'image/*',
-        'video/*',
-        '.3fr',
-        '.3gp',
-        '.ari',
-        '.arw',
-        '.avif',
-        '.cap',
-        '.cin',
-        '.cr2',
-        '.cr3',
-        '.crw',
-        '.dcr',
-        '.dng',
-        '.erf',
-        '.fff',
-        '.heic',
-        '.heif',
-        '.iiq',
-        '.insp',
-        '.insv',
-        '.jxl',
-        '.k25',
-        '.kdc',
-        '.m2ts',
-        '.mov',
-        '.mrw',
-        '.mts',
-        '.nef',
-        '.orf',
-        '.ori',
-        '.pef',
-        '.raf',
-        '.raf',
-        '.raw',
-        '.rwl',
-        '.sr2',
-        '.srf',
-        '.srw',
-        '.x3f',
-      ].join(',');
-
+      fileSelector.accept = extensions.join(',');
       fileSelector.onchange = async (e: Event) => {
         const target = e.target as HTMLInputElement;
         if (!target.files) {
@@ -87,10 +91,7 @@ export const fileUploadHandler = async (
 ) => {
   return firstValueFrom(
     from(files).pipe(
-      filter((file) => {
-        const assetType = getFileMimeType(file).split('/')[0];
-        return assetType === 'video' || assetType === 'image';
-      }),
+      filter((file) => extensions.includes('.' + getFilenameExtension(file.name))),
       mergeMap(async (file) => of(await fileUploader(file, albumId, sharedKey)), 2),
       combineLatestAll(),
     ),
@@ -103,9 +104,6 @@ async function fileUploader(
   albumId: string | undefined = undefined,
   sharedKey: string | undefined = undefined,
 ): Promise<string | undefined> {
-  const mimeType = getFileMimeType(asset);
-  const assetType = mimeType.split('/')[0].toUpperCase();
-  const fileExtension = getFilenameExtension(asset.name);
   const formData = new FormData();
   const fileCreatedAt = new Date(asset.lastModified).toISOString();
   const deviceAssetId = 'web' + '-' + asset.name + '-' + asset.lastModified;
@@ -117,9 +115,6 @@ async function fileUploader(
     // Get device id - for web -> use WEB
     formData.append('deviceId', 'WEB');
 
-    // Get asset type
-    formData.append('assetType', assetType);
-
     // Get Asset Created Date
     formData.append('fileCreatedAt', fileCreatedAt);
 
@@ -132,19 +127,15 @@ async function fileUploader(
     // Get asset duration
     formData.append('duration', '0:00:00.000000');
 
-    // Get asset file extension
-    formData.append('fileExtension', '.' + fileExtension);
-
     // Get asset binary data with a custom MIME type, because browsers will
     // use application/octet-stream for unsupported MIME types, leading to
     // failed uploads.
-    formData.append('assetData', new File([asset], asset.name, { type: mimeType }));
+    formData.append('assetData', new File([asset], asset.name));
 
     const newUploadAsset: UploadAsset = {
       id: deviceAssetId,
       file: asset,
       progress: 0,
-      fileExtension: fileExtension,
     };
 
     uploadAssetsStore.addNewUploadAsset(newUploadAsset);