Przeglądaj źródła

feat(web): show download size (#3270)

* feat(web): show download size

* chore: never over 100%

* chore: use percentage

* fix: unselect assets before download finishes
Jason Rasmussen 2 lat temu
rodzic
commit
382341f550

+ 1 - 1
web/src/lib/components/album-page/album-viewer.svelte

@@ -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) => {

+ 30 - 10
web/src/lib/components/asset-viewer/download-panel.svelte

@@ -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,17 +19,28 @@
   >
     <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}
     </div>

+ 3 - 2
web/src/lib/components/photos-page/actions/download-action.svelte

@@ -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>
 

+ 1 - 6
web/src/lib/components/share-page/individual-shared-viewer.svelte

@@ -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[] = []) => {

+ 31 - 6
web/src/lib/stores/download.ts

@@ -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);
+  },
+};

+ 28 - 17
web/src/lib/utils/asset-utils.ts

@@ -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);
     }
   }
 };

+ 5 - 0
web/src/lib/utils/handle-error.ts

@@ -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;