From 4f4e283b57c1df14b3070dce293341e4c45ef061 Mon Sep 17 00:00:00 2001 From: Diogo Correia Date: Sat, 18 Jul 2026 02:11:34 +0100 Subject: [PATCH 1/7] fix: download archives via html POST forms --- server/src/controllers/download.controller.ts | 12 +++++++-- server/src/dtos/download.dto.ts | 1 + .../middleware/forms-to-json.interceptor.ts | 26 ++++++++++++++++++ .../components/album-page/AlbumViewer.svelte | 2 +- .../share-page/IndividualSharedViewer.svelte | 4 +-- .../timeline/actions/DownloadAction.svelte | 2 +- .../lib/managers/download-manager.svelte.ts | 27 ++++++++----------- web/src/lib/services/album.service.ts | 2 +- web/src/lib/utils.ts | 25 ++++++++++++++--- web/src/lib/utils/asset-utils.ts | 27 +++++++------------ .../[[assetId=id]]/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 2 +- web/src/routes/DownloadPanel.svelte | 27 +++++++------------ 13 files changed, 95 insertions(+), 64 deletions(-) create mode 100644 server/src/middleware/forms-to-json.interceptor.ts diff --git a/server/src/controllers/download.controller.ts b/server/src/controllers/download.controller.ts index e45eeb23f3..7c5958a049 100644 --- a/server/src/controllers/download.controller.ts +++ b/server/src/controllers/download.controller.ts @@ -1,10 +1,12 @@ -import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Post, Res, StreamableFile, UseInterceptors } from '@nestjs/common'; +import { Response } from 'express'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadArchiveDto, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; +import { FormsToJsonInterceptor } from 'src/middleware/forms-to-json.interceptor'; import { DownloadService } from 'src/services/download.service'; import { asStreamableFile } from 'src/utils/file'; @@ -29,13 +31,19 @@ export class DownloadController { @Authenticated({ permission: Permission.AssetDownload, sharedLink: true }) @FileResponse() @HttpCode(HttpStatus.OK) + @UseInterceptors(FormsToJsonInterceptor) @Endpoint({ summary: 'Download asset archive', description: 'Download a ZIP archive containing the specified assets. The assets must have been previously requested via the "getDownloadInfo" endpoint.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - downloadArchive(@Auth() auth: AuthDto, @Body() dto: DownloadArchiveDto): Promise { + downloadArchive(@Res({ passthrough: true }) res: Response, @Auth() auth: AuthDto, @Body() dto: DownloadArchiveDto): Promise { + if (dto.archiveName) { + res.set({ + 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(dto.archiveName)}.zip`, + }) + } return this.service.downloadArchive(auth, dto).then(asStreamableFile); } } diff --git a/server/src/dtos/download.dto.ts b/server/src/dtos/download.dto.ts index c37fa5c113..5f504449c4 100644 --- a/server/src/dtos/download.dto.ts +++ b/server/src/dtos/download.dto.ts @@ -27,6 +27,7 @@ const DownloadResponseSchema = z const DownloadArchiveSchema = AssetIdsSchema.extend({ edited: 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/middleware/forms-to-json.interceptor.ts b/server/src/middleware/forms-to-json.interceptor.ts new file mode 100644 index 0000000000..aa2b325e4e --- /dev/null +++ b/server/src/middleware/forms-to-json.interceptor.ts @@ -0,0 +1,26 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { Request } from 'express'; + +@Injectable() +export class FormsToJsonInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + + const contentType = req.headers['content-type']; + if (contentType?.startsWith('application/x-www-form-urlencoded')) { + try { + req.body = JSON.parse(req?.body?.json); + req.headers['content-type'] = 'application/json'; + } catch { + } + } + + return next.handle(); + } +} diff --git a/web/src/lib/components/album-page/AlbumViewer.svelte b/web/src/lib/components/album-page/AlbumViewer.svelte index 4e7d6bce1a..aab28d110c 100644 --- a/web/src/lib/components/album-page/AlbumViewer.svelte +++ b/web/src/lib/components/album-page/AlbumViewer.svelte @@ -99,7 +99,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 a3cf76c78e..476437ebd9 100644 --- a/web/src/lib/components/share-page/IndividualSharedViewer.svelte +++ b/web/src/lib/components/share-page/IndividualSharedViewer.svelte @@ -42,7 +42,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[] = []) => { @@ -91,7 +91,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 107f80b8dc..d548db5b21 100644 --- a/web/src/lib/managers/download-manager.svelte.ts +++ b/web/src/lib/managers/download-manager.svelte.ts @@ -1,44 +1,39 @@ -export interface DownloadProgress { - progress: number; +export interface DownloadState { + url: string; + payload: unknown; total: number; - percentage: number; - abort: AbortController | null; + downloaded: boolean; } class DownloadManager { - assets = $state>({}); + assets = $state>({}); isDownloading = $derived(Object.keys(this.assets).length > 0); - #update(key: string, value: Partial | null) { + #update(key: string, value: Partial | null) { if (value === null) { delete this.assets[key]; return; } if (!this.assets[key]) { - this.assets[key] = { progress: 0, total: 0, percentage: 0, abort: null }; + this.assets[key] = { url: "", payload: undefined, total: 0, downloaded: false }; } const item = this.assets[key]; Object.assign(item, value); - item.percentage = Math.min(Math.floor((item.progress / item.total) * 100), 100); } - add(key: string, total: number, abort?: AbortController) { - this.#update(key, { total, abort }); + add(key: string, url: string, payload: unknown, total: number) { + this.#update(key, { url, payload, total }); } clear(key: string) { this.#update(key, null); } - update(key: string, progress: number, total?: number) { - const download: Partial = { progress }; - if (total !== undefined) { - download.total = total; - } - this.#update(key, download); + markDownloaded(key: string) { + this.#update(key, { downloaded: true }); } } 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 0e31782164..7aa3fe2097 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'; @@ -294,15 +293,33 @@ export const downloadUrl = (url: string, filename: string) => { URL.revokeObjectURL(url); }; +export const downloadUrlPost = (url: string, data: unknown) => { + const form = document.createElement("form"); + form.method = "post"; + form.action = url; + form.target = "_blank"; + + const inputJson = document.createElement("input"); + inputJson.type = "hidden"; + inputJson.name = "json"; + inputJson.value = JSON.stringify(data); + form.appendChild(inputJson); + + document.body.appendChild(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); + // downloadManager.add(downloadKey, blob.size); + // downloadManager.update(downloadKey, blob.size); downloadBlob(blob, downloadKey); - setTimeout(() => downloadManager.clear(downloadKey), 5000); + // setTimeout(() => downloadManager.clear(downloadKey), 5000); + // TODO show notification instead? }; export const oauth = { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index fdfc15e636..757704de77 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -27,7 +27,7 @@ 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 { 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 +92,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); let downloadKey = `${archiveName} `; @@ -100,27 +100,20 @@ 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, payload, archive.size); + } else { + downloadUrlPost(url, payload); + // TODO show notification/toast + } } 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 42072c122b..e2a40233d4 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 @@ -465,7 +465,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 1ef13c8504..383e7141bf 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 @@ -470,7 +470,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 { 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.payload); + downloadManager.markDownloaded(downloadKey); }; @@ -30,26 +31,16 @@

{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} />
From 52756bacd4a7d9ab6029443f5a98f5d61c5ac0e1 Mon Sep 17 00:00:00 2001 From: Diogo Correia Date: Sat, 18 Jul 2026 17:50:09 +0100 Subject: [PATCH 2/7] refactor: fix css of download panel, add translations and toasts --- i18n/en.json | 4 ++++ .../lib/managers/download-manager.svelte.ts | 4 ++-- web/src/lib/utils.ts | 7 +++---- web/src/lib/utils/asset-utils.ts | 5 ++++- web/src/routes/DownloadPanel.svelte | 19 +++++++++++++------ 5 files changed, 26 insertions(+), 13 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 53d1e4a6b2..55534eb5e6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -890,7 +890,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", @@ -1651,6 +1653,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", @@ -1788,6 +1791,7 @@ "restored_asset": "Restored asset", "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/web/src/lib/managers/download-manager.svelte.ts b/web/src/lib/managers/download-manager.svelte.ts index d548db5b21..3684f78fff 100644 --- a/web/src/lib/managers/download-manager.svelte.ts +++ b/web/src/lib/managers/download-manager.svelte.ts @@ -28,8 +28,8 @@ class DownloadManager { this.#update(key, { url, payload, total }); } - clear(key: string) { - this.#update(key, null); + clearAll() { + this.assets = {}; } markDownloaded(key: string) { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 7aa3fe2097..12df87216d 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -315,11 +315,10 @@ export const downloadBlob = (data: Blob, filename: string) => downloadUrl(URL.cr 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); - // TODO show notification instead? + + 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 757704de77..824b091ed7 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -34,6 +34,7 @@ import { navigate } from '$lib/utils/navigation'; import { asQueryString } from '$lib/utils/shared-links'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { handleError } from './handle-error'; +import { locale } from '$lib/stores/preferences.store'; export const tagAssets = async ({ assetIds, @@ -108,7 +109,9 @@ export const downloadArchive = async (fileName: string, options: Omit import { type DownloadState, downloadManager } from '$lib/managers/download-manager.svelte'; import { locale } from '$lib/stores/preferences.store'; - import { Heading, IconButton } from '@immich/ui'; + import { CloseButton, Heading, IconButton } from '@immich/ui'; import { mdiReload, mdiDownload } from '@mdi/js'; import { t } from 'svelte-i18n'; import { fly, slide } from 'svelte/transition'; @@ -12,19 +12,26 @@ downloadUrlPost(download.url, download.payload); 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]} -
-
+
+

{downloadKey}

{#if download.total} @@ -32,7 +39,7 @@ {/if}
-
+
Date: Sat, 18 Jul 2026 18:39:46 +0100 Subject: [PATCH 3/7] style: run lint, formatter, check --- server/src/controllers/download.controller.ts | 10 +++++++--- .../middleware/forms-to-json.interceptor.ts | 10 +++------- .../lib/managers/download-manager.svelte.ts | 2 +- web/src/lib/utils.ts | 20 +++++++++---------- web/src/lib/utils/asset-utils.ts | 9 +++++++-- web/src/routes/DownloadPanel.svelte | 6 +++--- 6 files changed, 31 insertions(+), 26 deletions(-) diff --git a/server/src/controllers/download.controller.ts b/server/src/controllers/download.controller.ts index 7c5958a049..593f72b241 100644 --- a/server/src/controllers/download.controller.ts +++ b/server/src/controllers/download.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, HttpCode, HttpStatus, Post, Res, StreamableFile, UseInterceptors } from '@nestjs/common'; -import { Response } from 'express'; import { ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadArchiveDto, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; @@ -38,11 +38,15 @@ export class DownloadController { 'Download a ZIP archive containing the specified assets. The assets must have been previously requested via the "getDownloadInfo" endpoint.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - downloadArchive(@Res({ passthrough: true }) res: Response, @Auth() auth: AuthDto, @Body() dto: DownloadArchiveDto): Promise { + downloadArchive( + @Res({ passthrough: true }) res: Response, + @Auth() auth: AuthDto, + @Body() dto: DownloadArchiveDto, + ): Promise { if (dto.archiveName) { res.set({ 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(dto.archiveName)}.zip`, - }) + }); } return this.service.downloadArchive(auth, dto).then(asStreamableFile); } diff --git a/server/src/middleware/forms-to-json.interceptor.ts b/server/src/middleware/forms-to-json.interceptor.ts index aa2b325e4e..400e557ad3 100644 --- a/server/src/middleware/forms-to-json.interceptor.ts +++ b/server/src/middleware/forms-to-json.interceptor.ts @@ -1,11 +1,6 @@ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, -} from '@nestjs/common'; -import { Observable } from 'rxjs'; +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Request } from 'express'; +import { Observable } from 'rxjs'; @Injectable() export class FormsToJsonInterceptor implements NestInterceptor { @@ -18,6 +13,7 @@ export class FormsToJsonInterceptor implements NestInterceptor { req.body = JSON.parse(req?.body?.json); req.headers['content-type'] = 'application/json'; } catch { + // ignore if failed to parse } } diff --git a/web/src/lib/managers/download-manager.svelte.ts b/web/src/lib/managers/download-manager.svelte.ts index 3684f78fff..380622e36a 100644 --- a/web/src/lib/managers/download-manager.svelte.ts +++ b/web/src/lib/managers/download-manager.svelte.ts @@ -17,7 +17,7 @@ class DownloadManager { } if (!this.assets[key]) { - this.assets[key] = { url: "", payload: undefined, total: 0, downloaded: false }; + this.assets[key] = { url: '', payload: undefined, total: 0, downloaded: false }; } const item = this.assets[key]; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 12df87216d..7c4813db83 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -294,21 +294,21 @@ export const downloadUrl = (url: string, filename: string) => { }; export const downloadUrlPost = (url: string, data: unknown) => { - const form = document.createElement("form"); - form.method = "post"; + const form = document.createElement('form'); + form.method = 'post'; form.action = url; - form.target = "_blank"; + form.target = '_blank'; - const inputJson = document.createElement("input"); - inputJson.type = "hidden"; - inputJson.name = "json"; + const inputJson = document.createElement('input'); + inputJson.type = 'hidden'; + inputJson.name = 'json'; inputJson.value = JSON.stringify(data); - form.appendChild(inputJson); + form.append(inputJson); - document.body.appendChild(form); + document.body.append(form); form.submit(); form.remove(); -} +}; export const downloadBlob = (data: Blob, filename: string) => downloadUrl(URL.createObjectURL(data), filename); @@ -318,7 +318,7 @@ export const downloadJson = (data: unknown, filename: string) => { downloadBlob(blob, downloadKey); const $t = get(t); - toastManager.info($t('downloading_filename', {values: {filename}})); + 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 824b091ed7..fa20c7e8f0 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -27,6 +27,7 @@ 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 { 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'; @@ -34,7 +35,6 @@ import { navigate } from '$lib/utils/navigation'; import { asQueryString } from '$lib/utils/shared-links'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { handleError } from './handle-error'; -import { locale } from '$lib/stores/preferences.store'; export const tagAssets = async ({ assetIds, @@ -111,7 +111,12 @@ export const downloadArchive = async (fileName: string, options: Omit { downloadManager.clearAll(); - } + }; {#if downloadManager.isDownloading} @@ -25,13 +25,13 @@ >
{$t('prepared_archives')} - +
{#each Object.keys(downloadManager.assets) as downloadKey (downloadKey)} {@const download = downloadManager.assets[downloadKey]}
-
+

{downloadKey}

{#if download.total} From b4302ee99cc8fdf3c1f7056cf1911bf9f06c98c2 Mon Sep 17 00:00:00 2001 From: Diogo Correia Date: Sat, 18 Jul 2026 19:16:56 +0100 Subject: [PATCH 4/7] chore: generate open-api specs --- .../lib/model/download_archive_dto.dart | 19 ++++++++++++++++++- open-api/immich-openapi-specs.json | 4 ++++ packages/sdk/src/fetch-client.ts | 2 ++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/mobile/openapi/lib/model/download_archive_dto.dart b/mobile/openapi/lib/model/download_archive_dto.dart index f89ac8c867..d8a885737f 100644 --- a/mobile/openapi/lib/model/download_archive_dto.dart +++ b/mobile/openapi/lib/model/download_archive_dto.dart @@ -13,10 +13,20 @@ part of openapi.api; class DownloadArchiveDto { /// Returns a new [DownloadArchiveDto] instance. DownloadArchiveDto({ + this.archiveName = const Optional.absent(), this.assetIds = const [], this.edited = const Optional.absent(), }); + /// The name of the archive to download, without extension + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Optional archiveName; + /// Asset IDs List assetIds; @@ -31,20 +41,26 @@ class DownloadArchiveDto { @override bool operator ==(Object other) => identical(this, other) || other is DownloadArchiveDto && + other.archiveName == archiveName && _deepEquality.equals(other.assetIds, assetIds) && other.edited == edited; @override int get hashCode => // ignore: unnecessary_parenthesis + (archiveName == null ? 0 : archiveName!.hashCode) + (assetIds.hashCode) + (edited == null ? 0 : edited!.hashCode); @override - String toString() => 'DownloadArchiveDto[assetIds=$assetIds, edited=$edited]'; + String toString() => 'DownloadArchiveDto[archiveName=$archiveName, assetIds=$assetIds, edited=$edited]'; Map toJson() { final json = {}; + if (this.archiveName.isPresent) { + final value = this.archiveName.value; + json[r'archiveName'] = value; + } json[r'assetIds'] = this.assetIds; if (this.edited.isPresent) { final value = this.edited.value; @@ -62,6 +78,7 @@ class DownloadArchiveDto { final json = value.cast(); return DownloadArchiveDto( + archiveName: json.containsKey(r'archiveName') ? Optional.present(mapValueOfType(json, r'archiveName')) : const Optional.absent(), assetIds: json[r'assetIds'] is Iterable ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) : const [], diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e73760a3de..f812ba7fc6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -18650,6 +18650,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 8983de8ef7..82d2920bb4 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 */ From 0dd39fbb0e3ca67294c80fed8ee25d73f6ab1f59 Mon Sep 17 00:00:00 2001 From: Diogo Correia Date: Sun, 19 Jul 2026 13:50:07 +0100 Subject: [PATCH 5/7] refactor(web): simplify download manager, use SvelteMap --- .../lib/managers/download-manager.svelte.ts | 29 +++++++------------ web/src/routes/DownloadPanel.svelte | 3 +- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/web/src/lib/managers/download-manager.svelte.ts b/web/src/lib/managers/download-manager.svelte.ts index 380622e36a..09765e96d4 100644 --- a/web/src/lib/managers/download-manager.svelte.ts +++ b/web/src/lib/managers/download-manager.svelte.ts @@ -1,3 +1,5 @@ +import { SvelteMap } from 'svelte/reactivity'; + export interface DownloadState { url: string; payload: unknown; @@ -6,34 +8,23 @@ export interface DownloadState { } class DownloadManager { - assets = $state>({}); + assets = new SvelteMap(); - isDownloading = $derived(Object.keys(this.assets).length > 0); - - #update(key: string, value: Partial | null) { - if (value === null) { - delete this.assets[key]; - return; - } - - if (!this.assets[key]) { - this.assets[key] = { url: '', payload: undefined, total: 0, downloaded: false }; - } - - const item = this.assets[key]; - Object.assign(item, value); - } + isDownloading = $derived(this.assets.size > 0); add(key: string, url: string, payload: unknown, total: number) { - this.#update(key, { url, payload, total }); + this.assets.set(key, { url, payload, total, downloaded: false }); } clearAll() { - this.assets = {}; + this.assets.clear(); } markDownloaded(key: string) { - this.#update(key, { downloaded: true }); + const state = this.assets.get(key); + if (state) { + this.assets.set(key, { ...state, downloaded: true }); + } } } diff --git a/web/src/routes/DownloadPanel.svelte b/web/src/routes/DownloadPanel.svelte index 1562e2f548..e7cd0bd156 100644 --- a/web/src/routes/DownloadPanel.svelte +++ b/web/src/routes/DownloadPanel.svelte @@ -28,8 +28,7 @@
- {#each Object.keys(downloadManager.assets) as downloadKey (downloadKey)} - {@const download = downloadManager.assets[downloadKey]} + {#each downloadManager.assets as [downloadKey, download] (downloadKey)}
From 4c1eb53aaf92726bd04bb1ec06788096431a9869 Mon Sep 17 00:00:00 2001 From: Diogo Correia Date: Sun, 19 Jul 2026 14:59:27 +0100 Subject: [PATCH 6/7] refactor(server): parse fields from x-www-form-urlencoded request --- server/src/controllers/download.controller.ts | 4 +--- server/src/dtos/asset.dto.ts | 2 +- server/src/dtos/download.dto.ts | 13 ++++++++++- .../middleware/forms-to-json.interceptor.ts | 22 ------------------- .../lib/managers/download-manager.svelte.ts | 7 +++--- web/src/lib/utils.ts | 21 +++++++++++++----- web/src/lib/utils/asset-utils.ts | 5 ++--- web/src/routes/DownloadPanel.svelte | 2 +- 8 files changed, 36 insertions(+), 40 deletions(-) delete mode 100644 server/src/middleware/forms-to-json.interceptor.ts diff --git a/server/src/controllers/download.controller.ts b/server/src/controllers/download.controller.ts index 593f72b241..2bdf5c4837 100644 --- a/server/src/controllers/download.controller.ts +++ b/server/src/controllers/download.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, HttpCode, HttpStatus, Post, Res, StreamableFile, UseInterceptors } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Post, Res, StreamableFile } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { Endpoint, HistoryBuilder } from 'src/decorators'; @@ -6,7 +6,6 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadArchiveDto, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; -import { FormsToJsonInterceptor } from 'src/middleware/forms-to-json.interceptor'; import { DownloadService } from 'src/services/download.service'; import { asStreamableFile } from 'src/utils/file'; @@ -31,7 +30,6 @@ export class DownloadController { @Authenticated({ permission: Permission.AssetDownload, sharedLink: true }) @FileResponse() @HttpCode(HttpStatus.OK) - @UseInterceptors(FormsToJsonInterceptor) @Endpoint({ summary: 'Download asset archive', description: 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 5f504449c4..8fe33b447a 100644 --- a/server/src/dtos/download.dto.ts +++ b/server/src/dtos/download.dto.ts @@ -26,7 +26,18 @@ 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' }); diff --git a/server/src/middleware/forms-to-json.interceptor.ts b/server/src/middleware/forms-to-json.interceptor.ts deleted file mode 100644 index 400e557ad3..0000000000 --- a/server/src/middleware/forms-to-json.interceptor.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; -import { Request } from 'express'; -import { Observable } from 'rxjs'; - -@Injectable() -export class FormsToJsonInterceptor implements NestInterceptor { - intercept(context: ExecutionContext, next: CallHandler): Observable { - const req = context.switchToHttp().getRequest(); - - const contentType = req.headers['content-type']; - if (contentType?.startsWith('application/x-www-form-urlencoded')) { - try { - req.body = JSON.parse(req?.body?.json); - req.headers['content-type'] = 'application/json'; - } catch { - // ignore if failed to parse - } - } - - return next.handle(); - } -} diff --git a/web/src/lib/managers/download-manager.svelte.ts b/web/src/lib/managers/download-manager.svelte.ts index 09765e96d4..5ff0dfccb1 100644 --- a/web/src/lib/managers/download-manager.svelte.ts +++ b/web/src/lib/managers/download-manager.svelte.ts @@ -2,7 +2,8 @@ import { SvelteMap } from 'svelte/reactivity'; export interface DownloadState { url: string; - payload: unknown; + assetIds: string[]; + archiveName: string; total: number; downloaded: boolean; } @@ -12,8 +13,8 @@ class DownloadManager { isDownloading = $derived(this.assets.size > 0); - add(key: string, url: string, payload: unknown, total: number) { - this.assets.set(key, { url, payload, total, downloaded: false }); + add(key: string, url: string, assetIds: string[], archiveName: string, total: number) { + this.assets.set(key, { url, assetIds, archiveName, total, downloaded: false }); } clearAll() { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 7c4813db83..900f15fe26 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -293,17 +293,26 @@ export const downloadUrl = (url: string, filename: string) => { URL.revokeObjectURL(url); }; -export const downloadUrlPost = (url: string, data: unknown) => { +export const downloadUrlPost = (url: string, assetIds: string[], archiveName: string) => { const form = document.createElement('form'); form.method = 'post'; form.action = url; form.target = '_blank'; - const inputJson = document.createElement('input'); - inputJson.type = 'hidden'; - inputJson.name = 'json'; - inputJson.value = JSON.stringify(data); - form.append(inputJson); + 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(); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index fa20c7e8f0..5f77afc0c3 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -102,13 +102,12 @@ export const downloadArchive = async (fileName: string, options: Omit 1) { - downloadManager.add(downloadKey, url, payload, archive.size); + downloadManager.add(downloadKey, url, archive.assetIds, archiveName, archive.size); } else { - downloadUrlPost(url, payload); + downloadUrlPost(url, archive.assetIds, archiveName); const $t = await getFormatter(); const $locale = get(locale); toastManager.primary( diff --git a/web/src/routes/DownloadPanel.svelte b/web/src/routes/DownloadPanel.svelte index e7cd0bd156..82425ccab2 100644 --- a/web/src/routes/DownloadPanel.svelte +++ b/web/src/routes/DownloadPanel.svelte @@ -9,7 +9,7 @@ import { downloadUrlPost } from '$lib/utils'; const startDownload = (downloadKey: string, download: DownloadState) => { - downloadUrlPost(download.url, download.payload); + downloadUrlPost(download.url, download.assetIds, download.archiveName); downloadManager.markDownloaded(downloadKey); }; From d1f61de51d6d53e3575d37b0224861c5f3e5d916 Mon Sep 17 00:00:00 2001 From: Diogo Correia Date: Tue, 28 Jul 2026 16:05:15 +0100 Subject: [PATCH 7/7] refactor: move content-disposition to ImmichReadStream --- server/src/controllers/download.controller.ts | 14 ++------------ server/src/repositories/storage.repository.ts | 1 + server/src/services/download.service.ts | 5 ++++- server/src/utils/file.ts | 4 ++-- 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/server/src/controllers/download.controller.ts b/server/src/controllers/download.controller.ts index 2bdf5c4837..e45eeb23f3 100644 --- a/server/src/controllers/download.controller.ts +++ b/server/src/controllers/download.controller.ts @@ -1,6 +1,5 @@ -import { Body, Controller, HttpCode, HttpStatus, Post, Res, StreamableFile } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { Response } from 'express'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadArchiveDto, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; @@ -36,16 +35,7 @@ export class DownloadController { 'Download a ZIP archive containing the specified assets. The assets must have been previously requested via the "getDownloadInfo" endpoint.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - downloadArchive( - @Res({ passthrough: true }) res: Response, - @Auth() auth: AuthDto, - @Body() dto: DownloadArchiveDto, - ): Promise { - if (dto.archiveName) { - res.set({ - 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(dto.archiveName)}.zip`, - }); - } + downloadArchive(@Auth() auth: AuthDto, @Body() dto: DownloadArchiveDto): Promise { return this.service.downloadArchive(auth, dto).then(asStreamableFile); } } 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 }); };