This commit is contained in:
Diogo Correia 2026-08-14 15:28:44 -04:00 committed by GitHub
commit 7656046a0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 124 additions and 89 deletions

View file

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

View file

@ -18772,6 +18772,10 @@
},
"DownloadArchiveDto": {
"properties": {
"archiveName": {
"description": "The name of the archive to download, without extension",
"type": "string"
},
"assetIds": {
"description": "Asset IDs",
"items": {

View file

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

View file

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

View file

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

View file

@ -31,6 +31,7 @@ export interface WatchEvents {
export interface ImmichReadStream {
stream: Readable;
type?: string;
disposition?: string | string[];
length?: number;
}

View file

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

View file

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

View file

@ -101,7 +101,7 @@
<AssetSelectControlBar>
<SelectAllAssets {timelineManager} assetInteraction={assetMultiSelectManager} />
{#if sharedLink.allowDownload}
<DownloadAction filename="{album.albumName}.zip" />
<DownloadAction filename={album.albumName} />
{/if}
</AssetSelectControlBar>
{:else}

View file

@ -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}
<DownloadAction filename="immich-shared.zip" />
<DownloadAction filename="immich-shared" />
{/if}
{#if isOwned}
<RemoveFromSharedLink bind:sharedLink />

View file

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

View file

@ -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<Record<string, DownloadProgress>>({});
assets = new SvelteMap<string, DownloadState>();
isDownloading = $derived(Object.keys(this.assets).length > 0);
isDownloading = $derived(this.assets.size > 0);
#update(key: string, value: Partial<DownloadProgress> | 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<DownloadProgress> = { 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);
}
}

View file

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

View file

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

View file

@ -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<DownloadIn
for (let index = 0; index < downloadInfo.archives.length; index++) {
const archive = downloadInfo.archives[index];
const suffix = downloadInfo.archives.length > 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<DownloadIn
? `${archiveName} (${index + 1}/${downloadInfo.archives.length})`
: `${archiveName} `;
const abort = new AbortController();
downloadManager.add(downloadKey, archive.size, abort);
const url = getBaseUrl() + '/download/archive' + (queryParams ? `?${queryParams}` : '');
try {
// TODO use sdk once it supports progress events
const { data } = await downloadRequest({
method: 'POST',
url: getBaseUrl() + '/download/archive' + (queryParams ? `?${queryParams}` : ''),
data: { assetIds: archive.assetIds, edited: true },
signal: abort.signal,
onDownloadProgress: (event) => 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);
}
}
};

View file

@ -469,7 +469,7 @@
></FavoriteAction>
{/if}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}>
<DownloadAction menuItem filename="{album.albumName}.zip" />
<DownloadAction menuItem filename={album.albumName} />
{#if assetMultiSelectManager.isAllUserOwned}
<ChangeDate menuItem />
<ChangeDescription menuItem />

View file

@ -472,7 +472,7 @@
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
<DownloadAction menuItem filename={person.name || 'immich'} />
<MenuOption
icon={mdiAccountMultipleCheckOutline}
text={$t('fix_incorrect_match')}

View file

@ -1,55 +1,52 @@
<script lang="ts">
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();
};
</script>
{#if downloadManager.isDownloading}
<div
transition:fly={{ x: -100, duration: 350 }}
class="fixed inset-s-2 bottom-10 z-60 max-h-67.5 w-79 rounded-2xl border bg-subtle p-4 shadow-lg dark:border-white/10"
class="fixed inset-s-2 bottom-10 z-60 max-h-67.5 w-89 rounded-2xl border bg-subtle p-4 shadow-lg dark:border-white/10"
>
<Heading size="tiny">{$t('downloading')}</Heading>
<div class="flex items-center justify-between gap-2">
<Heading size="tiny">{$t('prepared_archives')}</Heading>
<CloseButton class="w-8" size="small" onclick={closePanel} />
</div>
<div class="my-2 mb-2 flex max-h-50 flex-col overflow-y-auto text-sm">
{#each Object.keys(downloadManager.assets) as downloadKey (downloadKey)}
{@const download = downloadManager.assets[downloadKey]}
<div class="mb-2 flex place-items-center" transition:slide>
<div class="w-full pe-10">
{#each downloadManager.assets as [downloadKey, download] (downloadKey)}
<div class="mb-2 flex place-items-center gap-2" transition:slide>
<div class="min-w-0 grow">
<div class="flex place-items-center justify-between gap-2 text-xs font-medium">
<p class="truncate">{downloadKey}</p>
{#if download.total}
<p class="whitespace-nowrap">{getByteUnitString(download.total, $locale)}</p>
{/if}
</div>
<div class="flex place-items-center gap-2">
<div class="h-2.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-600">
<div class="h-2.5 rounded-full bg-primary" style={`width: ${download.percentage}%`}></div>
</div>
<p class="min-w-16 text-right whitespace-nowrap">
<span class="text-primary">
{(download.percentage / 100).toLocaleString($locale, { style: 'percent' })}
</span>
</p>
</div>
</div>
<div class="absolute inset-e-4">
<div class="w-8">
<IconButton
color="secondary"
variant="outline"
shape="round"
aria-label={$t('close')}
onclick={() => abort(downloadKey, download)}
aria-label={$t(download.downloaded ? 'retry' : 'download')}
onclick={() => startDownload(downloadKey, download)}
size="tiny"
icon={mdiClose}
icon={download.downloaded ? mdiReload : mdiDownload}
/>
</div>
</div>