mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(web): show download size (#3270)
* feat(web): show download size * chore: never over 100% * chore: use percentage * fix: unselect assets before download finishes
This commit is contained in:
		| @@ -235,7 +235,7 @@ | ||||
|   }; | ||||
|  | ||||
|   const downloadAlbum = async () => { | ||||
|     await downloadArchive(`${album.albumName}.zip`, { albumId: album.id }, undefined, sharedLink?.key); | ||||
|     await downloadArchive(`${album.albumName}.zip`, { albumId: album.id }, sharedLink?.key); | ||||
|   }; | ||||
|  | ||||
|   const showAlbumOptionsMenu = ({ x, y }: MouseEvent) => { | ||||
|   | ||||
| @@ -1,6 +1,15 @@ | ||||
| <script lang="ts"> | ||||
|   import { downloadAssets, isDownloading } from '$lib/stores/download'; | ||||
|   import { DownloadProgress, downloadAssets, downloadManager, isDownloading } from '$lib/stores/download'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
|   import Close from 'svelte-material-icons/Close.svelte'; | ||||
|   import { fly, slide } from 'svelte/transition'; | ||||
|   import { asByteUnitString } from '../../utils/byte-units'; | ||||
|   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; | ||||
|  | ||||
|   const abort = (downloadKey: string, download: DownloadProgress) => { | ||||
|     download.abort?.abort(); | ||||
|     downloadManager.clear(downloadKey); | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| {#if $isDownloading} | ||||
| @@ -10,16 +19,27 @@ | ||||
|   > | ||||
|     <p class="text-gray-500 text-xs mb-2">DOWNLOADING</p> | ||||
|     <div class="max-h-[200px] my-2 overflow-y-auto mb-2 flex flex-col text-sm"> | ||||
|       {#each Object.keys($downloadAssets) as fileName} | ||||
|         <div class="mb-2" transition:slide> | ||||
|           <p class="font-medium text-xs truncate">■ {fileName}</p> | ||||
|           <div class="flex flex-row-reverse place-items-center gap-5"> | ||||
|             <p> | ||||
|               <span class="text-immich-primary font-medium">{$downloadAssets[fileName]}</span>/100 | ||||
|             </p> | ||||
|             <div class="w-full bg-gray-200 rounded-full h-[7px] dark:bg-gray-700"> | ||||
|               <div class="bg-immich-primary h-[7px] rounded-full" style={`width: ${$downloadAssets[fileName]}%`} /> | ||||
|       {#each Object.keys($downloadAssets) as downloadKey (downloadKey)} | ||||
|         {@const download = $downloadAssets[downloadKey]} | ||||
|         <div class="mb-2 flex place-items-center" transition:slide> | ||||
|           <div class="w-full pr-10"> | ||||
|             <div class="font-medium text-xs flex gap-2 place-items-center justify-between"> | ||||
|               <p class="truncate">■ {downloadKey}</p> | ||||
|               {#if download.total} | ||||
|                 <p class="whitespace-nowrap">{asByteUnitString(download.total, $locale)}</p> | ||||
|               {/if} | ||||
|             </div> | ||||
|             <div class="flex place-items-center gap-2"> | ||||
|               <div class="w-full bg-gray-200 rounded-full h-[7px] dark:bg-gray-700"> | ||||
|                 <div class="bg-immich-primary h-[7px] rounded-full" style={`width: ${download.percentage}%`} /> | ||||
|               </div> | ||||
|               <p class="whitespace-nowrap min-w-[4em] text-right"> | ||||
|                 <span class="text-immich-primary">{download.percentage}%</span> | ||||
|               </p> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="absolute right-2"> | ||||
|             <CircleIconButton on:click={() => abort(downloadKey, download)} size="20" logo={Close} forceDark /> | ||||
|           </div> | ||||
|         </div> | ||||
|       {/each} | ||||
|   | ||||
| @@ -14,12 +14,13 @@ | ||||
|   const handleDownloadFiles = async () => { | ||||
|     const assets = Array.from(getAssets()); | ||||
|     if (assets.length === 1) { | ||||
|       await downloadFile(assets[0], sharedLinkKey); | ||||
|       clearSelect(); | ||||
|       await downloadFile(assets[0], sharedLinkKey); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) }, clearSelect, sharedLinkKey); | ||||
|     clearSelect(); | ||||
|     await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) }, sharedLinkKey); | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -35,12 +35,7 @@ | ||||
|   }); | ||||
|  | ||||
|   const downloadAssets = async () => { | ||||
|     await downloadArchive( | ||||
|       `immich-shared.zip`, | ||||
|       { assetIds: assets.map((asset) => asset.id) }, | ||||
|       undefined, | ||||
|       sharedLink.key, | ||||
|     ); | ||||
|     await downloadArchive(`immich-shared.zip`, { assetIds: assets.map((asset) => asset.id) }, sharedLink.key); | ||||
|   }; | ||||
|  | ||||
|   const handleUploadAssets = async (files: File[] = []) => { | ||||
|   | ||||
| @@ -1,6 +1,13 @@ | ||||
| import { derived, writable } from 'svelte/store'; | ||||
|  | ||||
| export const downloadAssets = writable<Record<string, number>>({}); | ||||
| export interface DownloadProgress { | ||||
|   progress: number; | ||||
|   total: number; | ||||
|   percentage: number; | ||||
|   abort: AbortController | null; | ||||
| } | ||||
|  | ||||
| export const downloadAssets = writable<Record<string, DownloadProgress>>({}); | ||||
|  | ||||
| export const isDownloading = derived(downloadAssets, ($downloadAssets) => { | ||||
|   if (Object.keys($downloadAssets).length == 0) { | ||||
| @@ -10,17 +17,35 @@ export const isDownloading = derived(downloadAssets, ($downloadAssets) => { | ||||
|   return true; | ||||
| }); | ||||
|  | ||||
| const update = (key: string, value: number | null) => { | ||||
| const update = (key: string, value: Partial<DownloadProgress> | null) => { | ||||
|   downloadAssets.update((state) => { | ||||
|     const newState = { ...state }; | ||||
|  | ||||
|     if (value === null) { | ||||
|       delete newState[key]; | ||||
|     } else { | ||||
|       newState[key] = value; | ||||
|       return newState; | ||||
|     } | ||||
|  | ||||
|     if (!newState[key]) { | ||||
|       newState[key] = { progress: 0, total: 0, percentage: 0, abort: null }; | ||||
|     } | ||||
|  | ||||
|     const item = newState[key]; | ||||
|     Object.assign(item, value); | ||||
|     item.percentage = Math.min(Math.floor((item.progress / item.total) * 100), 100); | ||||
|  | ||||
|     return newState; | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export const clearDownload = (key: string) => update(key, null); | ||||
| export const updateDownload = (key: string, value: number) => update(key, value); | ||||
| export const downloadManager = { | ||||
|   add: (key: string, total: number, abort?: AbortController) => update(key, { total, abort }), | ||||
|   clear: (key: string) => update(key, null), | ||||
|   update: (key: string, progress: number, total?: number) => { | ||||
|     const download: Partial<DownloadProgress> = { progress }; | ||||
|     if (total !== undefined) { | ||||
|       download.total = total; | ||||
|     } | ||||
|     update(key, download); | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; | ||||
| import { clearDownload, updateDownload } from '$lib/stores/download'; | ||||
| import { downloadManager } from '$lib/stores/download'; | ||||
| import { AddAssetsResponseDto, api, AssetApiGetDownloadInfoRequest, AssetResponseDto, DownloadResponseDto } from '@api'; | ||||
| import { handleError } from './handle-error'; | ||||
|  | ||||
| @@ -37,7 +37,6 @@ const downloadBlob = (data: Blob, filename: string) => { | ||||
| export const downloadArchive = async ( | ||||
|   fileName: string, | ||||
|   options: Omit<AssetApiGetDownloadInfoRequest, 'key'>, | ||||
|   onDone?: () => void, | ||||
|   key?: string, | ||||
| ) => { | ||||
|   let downloadInfo: DownloadResponseDto | null = null; | ||||
| @@ -58,65 +57,77 @@ export const downloadArchive = async ( | ||||
|     const suffix = downloadInfo.archives.length === 1 ? '' : `+${i + 1}`; | ||||
|     const archiveName = fileName.replace('.zip', `${suffix}.zip`); | ||||
|  | ||||
|     let downloadKey = `${archiveName}`; | ||||
|     let downloadKey = `${archiveName} `; | ||||
|     if (downloadInfo.archives.length > 1) { | ||||
|       downloadKey = `${archiveName} (${i + 1}/${downloadInfo.archives.length})`; | ||||
|     } | ||||
|  | ||||
|     updateDownload(downloadKey, 0); | ||||
|     const abort = new AbortController(); | ||||
|     downloadManager.add(downloadKey, archive.size, abort); | ||||
|  | ||||
|     try { | ||||
|       const { data } = await api.assetApi.downloadArchive( | ||||
|         { assetIdsDto: { assetIds: archive.assetIds }, key }, | ||||
|         { | ||||
|           responseType: 'blob', | ||||
|           onDownloadProgress: (event) => updateDownload(downloadKey, Math.floor((event.loaded / archive.size) * 100)), | ||||
|           signal: abort.signal, | ||||
|           onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded), | ||||
|         }, | ||||
|       ); | ||||
|  | ||||
|       downloadBlob(data, archiveName); | ||||
|     } catch (e) { | ||||
|       handleError(e, 'Unable to download files'); | ||||
|       clearDownload(downloadKey); | ||||
|       downloadManager.clear(downloadKey); | ||||
|       return; | ||||
|     } finally { | ||||
|       setTimeout(() => clearDownload(downloadKey), 3_000); | ||||
|       setTimeout(() => downloadManager.clear(downloadKey), 5_000); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   onDone?.(); | ||||
| }; | ||||
|  | ||||
| export const downloadFile = async (asset: AssetResponseDto, key?: string) => { | ||||
|   const assets = [{ filename: `${asset.originalFileName}.${getFilenameExtension(asset.originalPath)}`, id: asset.id }]; | ||||
|   const assets = [ | ||||
|     { | ||||
|       filename: `${asset.originalFileName}.${getFilenameExtension(asset.originalPath)}`, | ||||
|       id: asset.id, | ||||
|       size: asset.exifInfo?.fileSizeInByte || 0, | ||||
|     }, | ||||
|   ]; | ||||
|   if (asset.livePhotoVideoId) { | ||||
|     assets.push({ | ||||
|       filename: `${asset.originalFileName}.mov`, | ||||
|       id: asset.livePhotoVideoId, | ||||
|       size: 0, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   for (const asset of assets) { | ||||
|   for (const { filename, id, size } of assets) { | ||||
|     const downloadKey = filename; | ||||
|  | ||||
|     try { | ||||
|       updateDownload(asset.filename, 0); | ||||
|       const abort = new AbortController(); | ||||
|       downloadManager.add(downloadKey, size, abort); | ||||
|  | ||||
|       const { data } = await api.assetApi.downloadFile( | ||||
|         { id: asset.id, key }, | ||||
|         { id, key }, | ||||
|         { | ||||
|           responseType: 'blob', | ||||
|           onDownloadProgress: (event: ProgressEvent) => { | ||||
|             if (event.lengthComputable) { | ||||
|               updateDownload(asset.filename, Math.floor((event.loaded / event.total) * 100)); | ||||
|               downloadManager.update(downloadKey, event.loaded, event.total); | ||||
|             } | ||||
|           }, | ||||
|           signal: abort.signal, | ||||
|         }, | ||||
|       ); | ||||
|  | ||||
|       downloadBlob(data, asset.filename); | ||||
|       downloadBlob(data, filename); | ||||
|     } catch (e) { | ||||
|       handleError(e, `Error downloading ${asset.filename}`); | ||||
|       handleError(e, `Error downloading ${filename}`); | ||||
|       downloadManager.clear(downloadKey); | ||||
|     } finally { | ||||
|       setTimeout(() => clearDownload(asset.filename), 3_000); | ||||
|       setTimeout(() => downloadManager.clear(downloadKey), 5_000); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|   | ||||
| @@ -1,7 +1,12 @@ | ||||
| import type { ApiError } from '@api'; | ||||
| import { CanceledError } from 'axios'; | ||||
| import { notificationController, NotificationType } from '../components/shared-components/notification/notification'; | ||||
|  | ||||
| export async function handleError(error: unknown, message: string) { | ||||
|   if (error instanceof CanceledError) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   console.error(`[handleError]: ${message}`, error); | ||||
|  | ||||
|   let data = (error as ApiError)?.response?.data; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user