diff --git a/i18n/en.json b/i18n/en.json index 57a8ae950..32be9d6c7 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -900,7 +900,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", @@ -1676,6 +1678,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", @@ -1815,6 +1818,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 2d471be15..4322e843d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -20799,6 +20799,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 e511b4d58..d562f6877 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1602,6 +1602,8 @@ export type UserConfigDto = { user: UserConfigUserDto; }; 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/controllers/download.controller.spec.ts b/server/src/controllers/download.controller.spec.ts new file mode 100644 index 000000000..ce3768895 --- /dev/null +++ b/server/src/controllers/download.controller.spec.ts @@ -0,0 +1,48 @@ +import { Readable } from 'node:stream'; +import { DownloadController } from 'src/controllers/download.controller'; +import { DownloadService } from 'src/services/download.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(DownloadController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(DownloadService); + + beforeAll(async () => { + ctx = await controllerSetup(DownloadController, [{ provide: DownloadService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /download/archive', () => { + it('should accept comma-separated assetIds string', async () => { + const downloadArchiveSpy = vi.spyOn(service, 'downloadArchive'); + service.downloadArchive.mockResolvedValue({ stream: Readable.from('') }); + + const ids = [factory.uuid(), factory.uuid()]; + const { status } = await request(ctx.getHttpServer()) + .post(`/download/archive`) + .type('form') + .send({ assetIds: ids.join(',') }); + expect(status).toBe(200); + expect(downloadArchiveSpy).toHaveBeenCalledWith(undefined, { assetIds: ids }); + }); + + it('should accept assetIds array', async () => { + const downloadArchiveSpy = vi.spyOn(service, 'downloadArchive'); + service.downloadArchive.mockResolvedValue({ stream: Readable.from('') }); + + const ids = [factory.uuid(), factory.uuid()]; + const { status } = await request(ctx.getHttpServer()).post(`/download/archive`).send({ + assetIds: ids, + }); + expect(status).toBe(200); + expect(downloadArchiveSpy).toHaveBeenCalledWith(undefined, { assetIds: ids }); + }); + }); +}); diff --git a/server/src/dtos/download.dto.ts b/server/src/dtos/download.dto.ts index c37fa5c11..fd61557d0 100644 --- a/server/src/dtos/download.dto.ts +++ b/server/src/dtos/download.dto.ts @@ -1,5 +1,4 @@ import { createZodDto } from 'nestjs-zod'; -import { AssetIdsSchema } from 'src/dtos/asset.dto'; import z from 'zod'; const DownloadInfoSchema = z @@ -25,9 +24,29 @@ const DownloadResponseSchema = z }) .meta({ id: 'DownloadResponseDto' }); -const DownloadArchiveSchema = AssetIdsSchema.extend({ - edited: z.boolean().optional().describe('Download edited asset if available'), -}).meta({ id: 'DownloadArchiveDto' }); +const DownloadArchiveSchema = z + .object({ + // Support receiving assetIds as a comma-separated string due to POST form submission. + // While we can send arrays, the total request parameter count is limited to 1000 fields, + // which would limit this DTO to a maximum of 1000 assets. + assetIds: z + .preprocess((val) => (typeof val === 'string' ? val.split(',') : val), z.array(z.uuidv4())) + .describe('Asset IDs'), + 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) {} export class DownloadResponseDto extends createZodDto(DownloadResponseSchema) {} diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 9604372fb..7ee74d987 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 3dc9c0dd0..c73da1954 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 24d555f2f..df3a0ce3e 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 7f294e2c2..2f1b43aee 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 a119f4ffe..7e7f056e1 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 43916c216..b1b683ca2 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 f08e5eb92..5ff0dfccb 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 08b1245e6..824c4fbb5 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -284,5 +284,5 @@ 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 }); }; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 3aecb5df5..43f99df30 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,38 @@ 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); + } + + mkInput('assetIds', assetIds.join(',')); + 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 5b8de3cfb..5918137fd 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 8f674f13c..3b3b598e3 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 @@ -473,7 +473,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 e4389f09d..7996f1655 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} />