diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fb05f0bb8..888c4f28da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -737,6 +737,9 @@ importers: happy-dom: specifier: ^18.0.1 version: 18.0.1 + hash-wasm: + specifier: ^4.12.0 + version: 4.12.0 intl-messageformat: specifier: ^10.7.11 version: 10.7.16 @@ -6762,6 +6765,9 @@ packages: resolution: {integrity: sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hash-wasm@4.12.0: + resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -18623,6 +18629,8 @@ snapshots: has-yarn@3.0.0: {} + hash-wasm@4.12.0: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 diff --git a/web/package.json b/web/package.json index 86574fde4f..e5ab08ca30 100644 --- a/web/package.json +++ b/web/package.json @@ -46,6 +46,7 @@ "geojson": "^0.5.0", "handlebars": "^4.7.8", "happy-dom": "^18.0.1", + "hash-wasm": "^4.12.0", "intl-messageformat": "^10.7.11", "justified-layout": "^4.1.0", "lodash-es": "^4.17.21", diff --git a/web/src/lib/components/shared-components/upload-asset-preview.svelte b/web/src/lib/components/shared-components/upload-asset-preview.svelte index 985400a033..1e186b3285 100644 --- a/web/src/lib/components/shared-components/upload-asset-preview.svelte +++ b/web/src/lib/components/shared-components/upload-asset-preview.svelte @@ -92,8 +92,8 @@ {#if uploadAsset.state === UploadState.STARTED}
-
-

+

+

{#if uploadAsset.message} {uploadAsset.message} {:else} diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index c572ec1760..4e97ad6123 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -7,6 +7,7 @@ import { uploadRequest } from '$lib/utils'; import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { ExecutorQueue } from '$lib/utils/executor-queue'; import { asQueryString } from '$lib/utils/shared-links'; +import { hashFile } from '$lib/utils/sw-messaging'; import { Action, AssetMediaStatus, @@ -154,16 +155,16 @@ async function fileUploader({ } let responseData: { id: string; status: AssetMediaStatus; isTrashed?: boolean } | undefined; - if (crypto?.subtle?.digest && !authManager.isSharedLink) { + if (!authManager.isSharedLink) { uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_hashing') }); await tick(); try { - const bytes = await assetFile.arrayBuffer(); - const hash = await crypto.subtle.digest('SHA-1', bytes); - const checksum = Array.from(new Uint8Array(hash)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - + const checksum = await hashFile(assetFile, { + id: deviceAssetId, + onProgress: (progress, total) => { + uploadAssetsStore.updateProgress(deviceAssetId, progress, total); + }, + }); const { results: [checkUploadResult], } = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: [{ id: assetFile.name, checksum }] } }); diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index 1a19d3c134..c8fde6efe7 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -1,8 +1,46 @@ +type OnProgress = (progress: number, total: number) => void; +type Callback = { onChecksum: (checksum: string) => void; onProgress: OnProgress }; + +const callbacks: Record = {}; const broadcast = new BroadcastChannel('immich'); -export function cancelImageUrl(url: string) { +broadcast.addEventListener('message', (event) => { + const { type, id, checksum, progress, total } = event.data; + + switch (type) { + case 'checksum': { + if (id && checksum) { + const callback = callbacks[id]; + callback?.onChecksum(checksum); + delete callbacks[id]; + } + break; + } + + case 'hash.progress': { + if (id && progress && total) { + const callback = callbacks[id]; + callback?.onProgress(progress, total); + } + break; + } + } +}); + +export const cancelImageUrl = (url: string) => { broadcast.postMessage({ type: 'cancel', url }); -} -export function preloadImageUrl(url: string) { +}; +export const preloadImageUrl = (url: string) => { broadcast.postMessage({ type: 'preload', url }); -} +}; + +export const hashFile = (file: File, { id, onProgress }: { id: string; onProgress: OnProgress }): Promise => { + return new Promise((onChecksum) => { + if (callbacks[id]) { + return; + } + + callbacks[id] = { onChecksum, onProgress }; + broadcast.postMessage({ type: 'hash', id, file }); + }); +}; diff --git a/web/src/service-worker/broadcast-channel.ts b/web/src/service-worker/broadcast-channel.ts index ae6f1e1be6..dfd10f0197 100644 --- a/web/src/service-worker/broadcast-channel.ts +++ b/web/src/service-worker/broadcast-channel.ts @@ -1,25 +1,68 @@ +import { createSHA1, sha1 } from 'hash-wasm'; import { handleCancel, handlePreload } from './request'; -export const installBroadcastChannelListener = () => { - const broadcast = new BroadcastChannel('immich'); - // eslint-disable-next-line unicorn/prefer-add-event-listener - broadcast.onmessage = (event) => { - if (!event.data) { - return; +type HashRequest = { id: string; file: File }; + +const MAX_HASH_FILE_SIZE = 100 * 1024 * 1024; // 100 MiB + +const broadcast = new BroadcastChannel('immich'); + +broadcast.addEventListener('message', async (event) => { + if (!event.data) { + return; + } + + const url = new URL(event.data.url, event.origin); + + switch (event.data.type) { + case 'preload': { + handlePreload(url); + break; } - const url = new URL(event.data.url, event.origin); - - switch (event.data.type) { - case 'preload': { - handlePreload(url); - break; - } - - case 'cancel': { - handleCancel(url); - break; - } + case 'cancel': { + handleCancel(url); + break; } - }; + + case 'hash': { + await handleHash(event.data); + } + } +}); + +const handleHash = async (request: HashRequest) => { + const { id, file } = request; + const checksum = file.size <= MAX_HASH_FILE_SIZE ? await hashSmallFile(request) : await hashLargeFile(request); + broadcast.postMessage({ type: 'checksum', id, checksum }); +}; + +const hashSmallFile = async ({ file }: HashRequest): Promise => { + const buffer = await file.arrayBuffer(); + return sha1(new Uint8Array(buffer)); +}; + +const hashLargeFile = async ({ id, file }: HashRequest): Promise => { + const sha1 = await createSHA1(); + const reader = file.stream().getReader(); + let processedBytes = 0; + let lastUpdate = Date.now(); + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + sha1.update(value); + processedBytes += value.length; + + broadcast.postMessage({ + type: 'hash.progress', + id, + progress: processedBytes, + total: file.size, + }); + } + + return sha1.digest('hex'); }; diff --git a/web/src/service-worker/index.ts b/web/src/service-worker/index.ts index 28336aca6a..a0c62deaa7 100644 --- a/web/src/service-worker/index.ts +++ b/web/src/service-worker/index.ts @@ -2,7 +2,7 @@ /// /// /// -import { installBroadcastChannelListener } from './broadcast-channel'; +import './broadcast-channel'; import { prune } from './cache'; import { handleRequest } from './request'; @@ -36,4 +36,3 @@ const handleFetch = (event: FetchEvent): void => { sw.addEventListener('install', handleInstall, { passive: true }); sw.addEventListener('activate', handleActivate, { passive: true }); sw.addEventListener('fetch', handleFetch, { passive: true }); -installBroadcastChannelListener();