Browse Source

feat(web): allow uploading more file types (#1570)

* feat(web): allow uploading more file types

* fix(web): make filename extension lowercase
Michel Heusschen 2 years ago
parent
commit
adb265794c

+ 69 - 0
web/src/lib/components/shared-components/upload-asset-preview.svelte

@@ -0,0 +1,69 @@
+<script lang="ts">
+	import { fade } from 'svelte/transition';
+	import { asByteUnitString } from '$lib/utils/byte-units';
+	import { UploadAsset } from '$lib/models/upload-asset';
+
+	export let uploadAsset: UploadAsset;
+
+	let showFallbackImage = false;
+	const previewURL = URL.createObjectURL(uploadAsset.file);
+</script>
+
+<div
+	in:fade={{ duration: 250 }}
+	out:fade={{ duration: 100 }}
+	class="text-xs mt-3 rounded-lg bg-immich-bg grid grid-cols-[70px_auto] gap-2 h-[70px]"
+>
+	<div class="relative">
+		{#if showFallbackImage}
+			<img
+				in:fade={{ duration: 250 }}
+				src="immich-logo.svg"
+				alt="Immich Logo"
+				class="h-[70px] w-[70px] object-cover rounded-tl-lg rounded-bl-lg"
+				draggable="false"
+			/>
+		{:else}
+			<img
+				in:fade={{ duration: 250 }}
+				on:load={() => {
+					URL.revokeObjectURL(previewURL);
+				}}
+				on:error={() => {
+					URL.revokeObjectURL(previewURL);
+					showFallbackImage = true;
+				}}
+				src={previewURL}
+				alt="Preview of asset"
+				class="h-[70px] w-[70px] object-cover rounded-tl-lg rounded-bl-lg"
+				draggable="false"
+			/>
+		{/if}
+
+		<div class="bottom-0 left-0 absolute w-full h-[25px] bg-immich-primary/30">
+			<p
+				class="absolute bottom-1 right-1 object-right-bottom text-gray-50/95 font-semibold stroke-immich-primary uppercase"
+			>
+				.{uploadAsset.fileExtension}
+			</p>
+		</div>
+	</div>
+
+	<div class="p-2 pr-4 flex flex-col justify-between">
+		<input
+			disabled
+			class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
+			value={`[${asByteUnitString(uploadAsset.file.size)}] ${uploadAsset.file.name}`}
+		/>
+
+		<div class="w-full bg-gray-300 h-[15px] rounded-md mt-[5px] text-white relative">
+			<div
+				class="bg-immich-primary h-[15px] rounded-md transition-all"
+				style={`width: ${uploadAsset.progress}%`}
+			/>
+			<p class="absolute h-full w-full text-center top-0 text-[10px] ">
+				{uploadAsset.progress}/100
+			</p>
+		</div>
+	</div>
+</div>

+ 4 - 80
web/src/lib/components/shared-components/upload-panel.svelte

@@ -4,55 +4,20 @@
 	import { uploadAssetsStore } from '$lib/stores/upload';
 	import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
 	import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
-	import type { UploadAsset } from '$lib/models/upload-asset';
 	import { notificationController, NotificationType } from './notification/notification';
-	import { asByteUnitString } from '$lib/utils/byte-units';
+	import UploadAssetPreview from './upload-asset-preview.svelte';
 
 	let showDetail = true;
-
 	let uploadLength = 0;
+	let isUploading = false;
 
-	const showUploadImageThumbnail = async (a: UploadAsset) => {
-		const extension = a.fileExtension.toLowerCase();
-
-		if (extension == 'jpeg' || extension == 'jpg' || extension == 'png') {
-			try {
-				const imgData = await a.file.arrayBuffer();
-				const arrayBufferView = new Uint8Array(imgData);
-				const blob = new Blob([arrayBufferView], { type: 'image/jpeg' });
-				const urlCreator = window.URL || window.webkitURL;
-				const imageUrl = urlCreator.createObjectURL(blob);
-				// TODO: There is probably a cleaner way of doing this
-				// eslint-disable-next-line @typescript-eslint/no-explicit-any
-				const img: any = document.getElementById(`${a.id}`);
-				img.src = imageUrl;
-			} catch {
-				// Do nothing?
-			}
-		}
-	};
-
-	// Reactive action to get thumbnail image of upload asset whenever there is a new one added to the list
+	// Reactive action to update asset uploadLength whenever there is a new one added to the list
 	$: {
 		if ($uploadAssetsStore.length != uploadLength) {
-			$uploadAssetsStore.map((asset) => {
-				showUploadImageThumbnail(asset);
-			});
-
 			uploadLength = $uploadAssetsStore.length;
 		}
 	}
 
-	$: {
-		if (showDetail) {
-			$uploadAssetsStore.map((asset) => {
-				showUploadImageThumbnail(asset);
-			});
-		}
-	}
-
-	let isUploading = false;
-
 	uploadAssetsStore.isUploading.subscribe((value) => {
 		isUploading = value;
 	});
@@ -88,48 +53,7 @@
 				<div class="max-h-[400px] overflow-y-auto pr-2 rounded-lg immich-scrollbar">
 					{#each $uploadAssetsStore as uploadAsset}
 						{#key uploadAsset.id}
-							<div
-								in:fade={{ duration: 250 }}
-								out:fade={{ duration: 100 }}
-								class="text-xs mt-3 rounded-lg bg-immich-bg grid grid-cols-[70px_auto] gap-2 h-[70px]"
-							>
-								<div class="relative">
-									<img
-										in:fade={{ duration: 250 }}
-										id={`${uploadAsset.id}`}
-										src="/immich-logo.svg"
-										alt=""
-										class="h-[70px] w-[70px] object-cover rounded-tl-lg rounded-bl-lg "
-										draggable="false"
-									/>
-
-									<div class="bottom-0 left-0 absolute w-full h-[25px] bg-immich-primary/30">
-										<p
-											class="absolute bottom-1 right-1 object-right-bottom text-gray-50/95 font-semibold stroke-immich-primary uppercase"
-										>
-											.{uploadAsset.fileExtension}
-										</p>
-									</div>
-								</div>
-
-								<div class="p-2 pr-4 flex flex-col justify-between">
-									<input
-										disabled
-										class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
-										value={`[${asByteUnitString(uploadAsset.file.size)}] ${uploadAsset.file.name}`}
-									/>
-
-									<div class="w-full bg-gray-300 h-[15px] rounded-md mt-[5px] text-white relative">
-										<div
-											class="bg-immich-primary h-[15px] rounded-md transition-all"
-											style={`width: ${uploadAsset.progress}%`}
-										/>
-										<p class="absolute h-full w-full text-center top-0 text-[10px] ">
-											{uploadAsset.progress}/100
-										</p>
-									</div>
-								</div>
-							</div>
+							<UploadAssetPreview {uploadAsset} />
 						{/key}
 					{/each}
 				</div>

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

@@ -111,3 +111,38 @@ export async function bulkDownload(
 		});
 	}
 }
+
+/**
+ * Returns the lowercase filename extension without a dot (.) and
+ * an empty string when not found.
+ */
+export function getFilenameExtension(filename: string): string {
+	const lastIndex = filename.lastIndexOf('.');
+	return filename.slice(lastIndex + 1).toLowerCase();
+}
+
+/**
+ * Returns the MIME type of the file and an empty string when not found.
+ */
+export function getFileMimeType(file: File): string {
+	if (file.type !== '') {
+		// Return the MIME type determined by the browser.
+		return file.type;
+	}
+
+	// Return MIME type based on the file extension.
+	switch (getFilenameExtension(file.name)) {
+		case 'heic':
+			return 'image/heic';
+		case 'heif':
+			return 'image/heif';
+		case 'dng':
+			return 'image/dng';
+		case '3gp':
+			return 'video/3gpp';
+		case 'nef':
+			return 'image/nef';
+		default:
+			return '';
+	}
+}

+ 15 - 9
web/src/lib/utils/file-uploader.ts

@@ -7,7 +7,7 @@ import * as exifr from 'exifr';
 import { uploadAssetsStore } from '$lib/stores/upload';
 import type { UploadAsset } from '../models/upload-asset';
 import { api, AssetFileUploadResponseDto } from '@api';
-import { addAssetsToAlbum } from '$lib/utils/asset-utils';
+import { addAssetsToAlbum, getFileMimeType, getFilenameExtension } from '$lib/utils/asset-utils';
 
 export const openFileUploadDialog = (
 	albumId: string | undefined = undefined,
@@ -19,6 +19,9 @@ export const openFileUploadDialog = (
 
 		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/*,.heic,.heif,.dng,.3gp,.nef';
 
 		fileSelector.onchange = async (e: Event) => {
@@ -55,9 +58,10 @@ export const fileUploadHandler = async (
 		return;
 	}
 
-	const acceptedFile = files.filter(
-		(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image'
-	);
+	const acceptedFile = files.filter((file) => {
+		const assetType = getFileMimeType(file).split('/')[0];
+		return assetType === 'video' || assetType === 'image';
+	});
 
 	for (const asset of acceptedFile) {
 		await fileUploader(asset, albumId, sharedKey, onDone);
@@ -71,9 +75,9 @@ async function fileUploader(
 	sharedKey: string | undefined = undefined,
 	onDone?: (id: string) => void
 ) {
-	const assetType = asset.type.split('/')[0].toUpperCase();
-	const temp = asset.name.split('.');
-	const fileExtension = temp[temp.length - 1];
+	const mimeType = getFileMimeType(asset);
+	const assetType = mimeType.split('/')[0].toUpperCase();
+	const fileExtension = getFilenameExtension(asset.name);
 	const formData = new FormData();
 
 	try {
@@ -114,8 +118,10 @@ async function fileUploader(
 		// Get asset file extension
 		formData.append('fileExtension', '.' + fileExtension);
 
-		// Get asset binary data.
-		formData.append('assetData', asset);
+		// 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 }));
 
 		// Check if asset upload on server before performing upload
 		const { data, status } = await api.assetApi.checkDuplicateAsset(