diff --git a/i18n/en.json b/i18n/en.json index 0acfe68fa3..749e07fa56 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -891,7 +891,9 @@ "download_settings_description": "Manage settings related to asset download", "download_waiting_to_retry": "Waiting to retry", "downloading": "Downloading", + "downloading_archive_filename_size": "Downloading archive {filename} (approximately {size})", "downloading_asset_filename": "Downloading asset {filename}", + "downloading_filename": "Downloading {filename}", "downloading_from_icloud": "Downloading from iCloud", "downloading_media": "Downloading media", "drag_to_reorder": "Drag to reorder", @@ -1653,6 +1655,7 @@ "port": "Port", "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", + "prepared_archives": "Prepared Archives", "preparing": "Preparing", "preset": "Preset", "preview": "Preview", @@ -1791,6 +1794,7 @@ "result": "Result", "resume": "Resume", "resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}", + "retry": "Retry", "review_duplicates": "Review duplicates", "review_large_files": "Review large files", "role": "Role", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d00faab9a0..0fbabfbd81 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -18772,6 +18772,10 @@ }, "DownloadArchiveDto": { "properties": { + "archiveName": { + "description": "The name of the archive to download, without extension", + "type": "string" + }, "assetIds": { "description": "Asset IDs", "items": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index b2f98b58ef..1d617405bb 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1114,6 +1114,8 @@ export type ValidateAccessTokenResponseDto = { authStatus: boolean; }; export type DownloadArchiveDto = { + /** The name of the archive to download, without extension */ + archiveName?: string; /** Asset IDs */ assetIds: string[]; /** Download edited asset if available */ diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 26c983a7de..4053ad0d6b 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -59,7 +59,7 @@ const AssetBulkDeleteSchema = BulkIdsSchema.extend({ export const AssetIdsSchema = z .object({ - assetIds: z.array(z.uuidv4()).describe('Asset IDs'), + assetIds: z.preprocess((val) => (typeof val === 'string' ? [val] : val), z.array(z.uuidv4())).describe('Asset IDs'), }) .meta({ id: 'AssetIdsDto' }); diff --git a/server/src/dtos/download.dto.ts b/server/src/dtos/download.dto.ts index c37fa5c113..8fe33b447a 100644 --- a/server/src/dtos/download.dto.ts +++ b/server/src/dtos/download.dto.ts @@ -26,7 +26,19 @@ const DownloadResponseSchema = z .meta({ id: 'DownloadResponseDto' }); const DownloadArchiveSchema = AssetIdsSchema.extend({ - edited: z.boolean().optional().describe('Download edited asset if available'), + edited: z + .preprocess((val) => { + if (val === 'true') { + return true; + } + if (val === 'false') { + return false; + } + return val; + }, z.boolean()) + .optional() + .describe('Download edited asset if available'), + archiveName: z.string().optional().describe('The name of the archive to download, without extension'), }).meta({ id: 'DownloadArchiveDto' }); export class DownloadInfoDto extends createZodDto(DownloadInfoSchema) {} diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 9604372fbe..7ee74d9870 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -31,6 +31,7 @@ export interface WatchEvents { export interface ImmichReadStream { stream: Readable; type?: string; + disposition?: string | string[]; length?: number; } diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 3dc9c0dd03..c73da19543 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -117,6 +117,9 @@ export class DownloadService extends BaseService { void zip.finalize(); - return { stream: zip.stream }; + return { + stream: zip.stream, + disposition: dto.archiveName && `attachment; filename*=UTF-8''${encodeURIComponent(dto.archiveName)}.zip`, + }; } } diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 24d555f2fe..df3a0ce3e9 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -86,6 +86,6 @@ export const sendFile = async ( } }; -export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => { - return new StreamableFile(stream, { type, length }); +export const asStreamableFile = ({ stream, type, disposition, length }: ImmichReadStream) => { + return new StreamableFile(stream, { type, disposition, length }); }; diff --git a/web/src/lib/components/album-page/AlbumViewer.svelte b/web/src/lib/components/album-page/AlbumViewer.svelte index 7f294e2c20..2f1b43aee3 100644 --- a/web/src/lib/components/album-page/AlbumViewer.svelte +++ b/web/src/lib/components/album-page/AlbumViewer.svelte @@ -101,7 +101,7 @@ {#if sharedLink.allowDownload} - + {/if} {:else} diff --git a/web/src/lib/components/share-page/IndividualSharedViewer.svelte b/web/src/lib/components/share-page/IndividualSharedViewer.svelte index a119f4ffea..7e7f056e1f 100644 --- a/web/src/lib/components/share-page/IndividualSharedViewer.svelte +++ b/web/src/lib/components/share-page/IndividualSharedViewer.svelte @@ -44,7 +44,7 @@ }); const downloadAssets = async () => { - await downloadArchive(`immich-shared.zip`, { assetIds: assets.map((asset) => asset.id) }); + await downloadArchive(`immich-shared`, { assetIds: assets.map((asset) => asset.id) }); }; const handleUploadAssets = async (files: File[] = []) => { @@ -93,7 +93,7 @@ onclick={handleSelectAll} /> {#if sharedLink?.allowDownload} - + {/if} {#if isOwned} diff --git a/web/src/lib/components/timeline/actions/DownloadAction.svelte b/web/src/lib/components/timeline/actions/DownloadAction.svelte index 43916c2165..b1b683ca28 100644 --- a/web/src/lib/components/timeline/actions/DownloadAction.svelte +++ b/web/src/lib/components/timeline/actions/DownloadAction.svelte @@ -16,7 +16,7 @@ menuItem?: boolean; } - let { filename = 'immich.zip', menuItem = false }: Props = $props(); + let { filename = 'immich', menuItem = false }: Props = $props(); const handleDownloadFiles = async () => { const assets = assetMultiSelectManager.assets; diff --git a/web/src/lib/managers/download-manager.svelte.ts b/web/src/lib/managers/download-manager.svelte.ts index f08e5eb929..5ff0dfccb1 100644 --- a/web/src/lib/managers/download-manager.svelte.ts +++ b/web/src/lib/managers/download-manager.svelte.ts @@ -1,44 +1,31 @@ -export interface DownloadProgress { - progress: number; +import { SvelteMap } from 'svelte/reactivity'; + +export interface DownloadState { + url: string; + assetIds: string[]; + archiveName: string; total: number; - percentage: number; - abort: AbortController | null; + downloaded: boolean; } class DownloadManager { - assets = $state>({}); + assets = new SvelteMap(); - isDownloading = $derived(Object.keys(this.assets).length > 0); + isDownloading = $derived(this.assets.size > 0); - #update(key: string, value: Partial | null) { - if (value === null) { - delete this.assets[key]; - return; - } - - if (!Object.hasOwn(this.assets, key)) { - this.assets[key] = { progress: 0, total: 0, percentage: 0, abort: null }; - } - - const item = this.assets[key]; - Object.assign(item, value); - item.percentage = Math.min(Math.floor((item.progress / item.total) * 100), 100); + add(key: string, url: string, assetIds: string[], archiveName: string, total: number) { + this.assets.set(key, { url, assetIds, archiveName, total, downloaded: false }); } - add(key: string, total: number, abort?: AbortController) { - this.#update(key, { total, abort }); + clearAll() { + this.assets.clear(); } - clear(key: string) { - this.#update(key, null); - } - - update(key: string, progress: number, total?: number) { - const download: Partial = { progress }; - if (total !== undefined) { - download.total = total; + markDownloaded(key: string) { + const state = this.assets.get(key); + if (state) { + this.assets.set(key, { ...state, downloaded: true }); } - this.#update(key, download); } } diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index fc09a11215..f4b34f9783 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -284,7 +284,7 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro }; export const handleDownloadAlbum = async (album: AlbumResponseDto) => { - await downloadArchive(`${album.albumName}.zip`, { albumId: album.id }); + await downloadArchive(album.albumName, { albumId: album.id }); }; export const handleConfirmAlbumDelete = async (album: AlbumResponseDto) => { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 3aecb5df59..98b762d163 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -24,7 +24,6 @@ import { init, register, t } from 'svelte-i18n'; import { derived, get } from 'svelte/store'; import { defaultLang, locales } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; -import { downloadManager } from '$lib/managers/download-manager.svelte'; import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store'; import { isWebCompatibleImage } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; @@ -293,15 +292,41 @@ export const downloadUrl = (url: string, filename: string) => { URL.revokeObjectURL(url); }; +export const downloadUrlPost = (url: string, assetIds: string[], archiveName: string) => { + const form = document.createElement('form'); + form.method = 'post'; + form.action = url; + form.target = '_blank'; + + function mkInput(name: string, value: string) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + form.append(input); + } + + for (const assetId of assetIds) { + mkInput('assetIds', assetId); + } + + mkInput('archiveName', archiveName); + mkInput('edited', 'true'); + + document.body.append(form); + form.submit(); + form.remove(); +}; + export const downloadBlob = (data: Blob, filename: string) => downloadUrl(URL.createObjectURL(data), filename); export const downloadJson = (data: unknown, filename: string) => { const blob = new Blob([JSON.stringify(data, jsonReplacer, 2)], { type: 'application/json' }); const downloadKey = filename; - downloadManager.add(downloadKey, blob.size); - downloadManager.update(downloadKey, blob.size); downloadBlob(blob, downloadKey); - setTimeout(() => downloadManager.clear(downloadKey), 5000); + + const $t = get(t); + toastManager.info($t('downloading_filename', { values: { filename } })); }; export const oauth = { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 5b8de3cfb0..5918137fd3 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -27,7 +27,8 @@ import { downloadManager } from '$lib/managers/download-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; -import { downloadBlob, downloadRequest, withError } from '$lib/utils'; +import { locale } from '$lib/stores/preferences.store'; +import { downloadUrlPost, withError } from '$lib/utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { getFormatter } from '$lib/utils/i18n'; import { navigate } from '$lib/utils/navigation'; @@ -92,7 +93,7 @@ export const downloadArchive = async (fileName: string, options: Omit 1 ? `+${index + 1}` : ''; - const archiveName = fileName.replace('.zip', () => `${suffix}-${DateTime.now().toFormat('yyyyLLdd_HHmmss')}.zip`); + const archiveName = `${fileName}${suffix}-${DateTime.now().toFormat('yyyyLLdd_HHmmss')}`; const queryParams = asQueryString(authManager.params); const downloadKey = @@ -100,27 +101,26 @@ export const downloadArchive = async (fileName: string, options: Omit downloadManager.update(downloadKey, event.loaded), - }); - - downloadBlob(data, archiveName); + if (downloadInfo.archives.length > 1) { + downloadManager.add(downloadKey, url, archive.assetIds, archiveName, archive.size); + } else { + downloadUrlPost(url, archive.assetIds, archiveName); + const $t = await getFormatter(); + const $locale = get(locale); + toastManager.primary( + $t('downloading_archive_filename_size', { + values: { size: getByteUnitString(archive.size, $locale), filename: archiveName }, + }), + { timeout: 10_000 }, + ); + } } catch (error) { const $t = get(t); handleError(error, $t('errors.unable_to_download_files')); - downloadManager.clear(downloadKey); return; - } finally { - setTimeout(() => downloadManager.clear(downloadKey), 5000); } } }; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 032a58ad52..4f718813cf 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -469,7 +469,7 @@ > {/if} - + {#if assetMultiSelectManager.isAllUserOwned} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index e4389f09dc..7996f1655f 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -472,7 +472,7 @@ onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} /> - + - import { type DownloadProgress, downloadManager } from '$lib/managers/download-manager.svelte'; + import { type DownloadState, downloadManager } from '$lib/managers/download-manager.svelte'; import { locale } from '$lib/stores/preferences.store'; - import { Heading, IconButton } from '@immich/ui'; - import { mdiClose } from '@mdi/js'; + import { CloseButton, Heading, IconButton } from '@immich/ui'; + import { mdiReload, mdiDownload } from '@mdi/js'; import { t } from 'svelte-i18n'; import { fly, slide } from 'svelte/transition'; import { getByteUnitString } from '$lib/utils/byte-units'; + import { downloadUrlPost } from '$lib/utils'; - const abort = (downloadKey: string, download: DownloadProgress) => { - download.abort?.abort(); - downloadManager.clear(downloadKey); + const startDownload = (downloadKey: string, download: DownloadState) => { + downloadUrlPost(download.url, download.assetIds, download.archiveName); + downloadManager.markDownloaded(downloadKey); + }; + + const closePanel = () => { + downloadManager.clearAll(); }; {#if downloadManager.isDownloading}
- {$t('downloading')} +
+ {$t('prepared_archives')} + +
- {#each Object.keys(downloadManager.assets) as downloadKey (downloadKey)} - {@const download = downloadManager.assets[downloadKey]} -
-
+ {#each downloadManager.assets as [downloadKey, download] (downloadKey)} +
+

{downloadKey}

{#if download.total}

{getByteUnitString(download.total, $locale)}

{/if}
-
-
-
-
-

- - {(download.percentage / 100).toLocaleString($locale, { style: 'percent' })} - -

-
-
+
abort(downloadKey, download)} + aria-label={$t(download.downloaded ? 'retry' : 'download')} + onclick={() => startDownload(downloadKey, download)} size="tiny" - icon={mdiClose} + icon={download.downloaded ? mdiReload : mdiDownload} />