fix(web): download archives via html POST forms (#30021)
* fix: download archives via html POST forms * refactor: fix css of download panel, add translations and toasts * style: run lint, formatter, check * chore: generate open-api specs * refactor(web): simplify download manager, use SvelteMap * refactor(server): parse fields from x-www-form-urlencoded request * refactor: move content-disposition to ImmichReadStream * Pass asset ids as a comma-separated string This fixes a bug where the 1000 parameter limit is being hit when downloading 1000+ assets. * Add download controller test * Fix lint warnings
This commit is contained in:
@@ -900,7 +900,9 @@
|
|||||||
"download_settings_description": "Manage settings related to asset download",
|
"download_settings_description": "Manage settings related to asset download",
|
||||||
"download_waiting_to_retry": "Waiting to retry",
|
"download_waiting_to_retry": "Waiting to retry",
|
||||||
"downloading": "Downloading",
|
"downloading": "Downloading",
|
||||||
|
"downloading_archive_filename_size": "Downloading archive {filename} (approximately {size})",
|
||||||
"downloading_asset_filename": "Downloading asset {filename}",
|
"downloading_asset_filename": "Downloading asset {filename}",
|
||||||
|
"downloading_filename": "Downloading {filename}",
|
||||||
"downloading_from_icloud": "Downloading from iCloud",
|
"downloading_from_icloud": "Downloading from iCloud",
|
||||||
"downloading_media": "Downloading media",
|
"downloading_media": "Downloading media",
|
||||||
"drag_to_reorder": "Drag to reorder",
|
"drag_to_reorder": "Drag to reorder",
|
||||||
@@ -1676,6 +1678,7 @@
|
|||||||
"port": "Port",
|
"port": "Port",
|
||||||
"preferences_settings_subtitle": "Manage the app's preferences",
|
"preferences_settings_subtitle": "Manage the app's preferences",
|
||||||
"preferences_settings_title": "Preferences",
|
"preferences_settings_title": "Preferences",
|
||||||
|
"prepared_archives": "Prepared Archives",
|
||||||
"preparing": "Preparing",
|
"preparing": "Preparing",
|
||||||
"preset": "Preset",
|
"preset": "Preset",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
@@ -1815,6 +1818,7 @@
|
|||||||
"result": "Result",
|
"result": "Result",
|
||||||
"resume": "Resume",
|
"resume": "Resume",
|
||||||
"resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}",
|
"resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}",
|
||||||
|
"retry": "Retry",
|
||||||
"review_duplicates": "Review duplicates",
|
"review_duplicates": "Review duplicates",
|
||||||
"review_large_files": "Review large files",
|
"review_large_files": "Review large files",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
|
|||||||
@@ -20799,6 +20799,10 @@
|
|||||||
},
|
},
|
||||||
"DownloadArchiveDto": {
|
"DownloadArchiveDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"archiveName": {
|
||||||
|
"description": "The name of the archive to download, without extension",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"assetIds": {
|
"assetIds": {
|
||||||
"description": "Asset IDs",
|
"description": "Asset IDs",
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
@@ -1602,6 +1602,8 @@ export type UserConfigDto = {
|
|||||||
user: UserConfigUserDto;
|
user: UserConfigUserDto;
|
||||||
};
|
};
|
||||||
export type DownloadArchiveDto = {
|
export type DownloadArchiveDto = {
|
||||||
|
/** The name of the archive to download, without extension */
|
||||||
|
archiveName?: string;
|
||||||
/** Asset IDs */
|
/** Asset IDs */
|
||||||
assetIds: string[];
|
assetIds: string[];
|
||||||
/** Download edited asset if available */
|
/** Download edited asset if available */
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { AssetIdsSchema } from 'src/dtos/asset.dto';
|
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
const DownloadInfoSchema = z
|
const DownloadInfoSchema = z
|
||||||
@@ -25,9 +24,29 @@ const DownloadResponseSchema = z
|
|||||||
})
|
})
|
||||||
.meta({ id: 'DownloadResponseDto' });
|
.meta({ id: 'DownloadResponseDto' });
|
||||||
|
|
||||||
const DownloadArchiveSchema = AssetIdsSchema.extend({
|
const DownloadArchiveSchema = z
|
||||||
edited: z.boolean().optional().describe('Download edited asset if available'),
|
.object({
|
||||||
}).meta({ id: 'DownloadArchiveDto' });
|
// 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 DownloadInfoDto extends createZodDto(DownloadInfoSchema) {}
|
||||||
export class DownloadResponseDto extends createZodDto(DownloadResponseSchema) {}
|
export class DownloadResponseDto extends createZodDto(DownloadResponseSchema) {}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface WatchEvents {
|
|||||||
export interface ImmichReadStream {
|
export interface ImmichReadStream {
|
||||||
stream: Readable;
|
stream: Readable;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
disposition?: string | string[];
|
||||||
length?: number;
|
length?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ export class DownloadService extends BaseService {
|
|||||||
|
|
||||||
void zip.finalize();
|
void zip.finalize();
|
||||||
|
|
||||||
return { stream: zip.stream };
|
return {
|
||||||
|
stream: zip.stream,
|
||||||
|
disposition: dto.archiveName && `attachment; filename*=UTF-8''${encodeURIComponent(dto.archiveName)}.zip`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,6 @@ export const sendFile = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
|
export const asStreamableFile = ({ stream, type, disposition, length }: ImmichReadStream) => {
|
||||||
return new StreamableFile(stream, { type, length });
|
return new StreamableFile(stream, { type, disposition, length });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
<AssetSelectControlBar>
|
<AssetSelectControlBar>
|
||||||
<SelectAllAssets {timelineManager} assetInteraction={assetMultiSelectManager} />
|
<SelectAllAssets {timelineManager} assetInteraction={assetMultiSelectManager} />
|
||||||
{#if sharedLink.allowDownload}
|
{#if sharedLink.allowDownload}
|
||||||
<DownloadAction filename="{album.albumName}.zip" />
|
<DownloadAction filename={album.albumName} />
|
||||||
{/if}
|
{/if}
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const downloadAssets = async () => {
|
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[] = []) => {
|
const handleUploadAssets = async (files: File[] = []) => {
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
onclick={handleSelectAll}
|
onclick={handleSelectAll}
|
||||||
/>
|
/>
|
||||||
{#if sharedLink?.allowDownload}
|
{#if sharedLink?.allowDownload}
|
||||||
<DownloadAction filename="immich-shared.zip" />
|
<DownloadAction filename="immich-shared" />
|
||||||
{/if}
|
{/if}
|
||||||
{#if isOwned}
|
{#if isOwned}
|
||||||
<RemoveFromSharedLink bind:sharedLink />
|
<RemoveFromSharedLink bind:sharedLink />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { filename = 'immich.zip', menuItem = false }: Props = $props();
|
let { filename = 'immich', menuItem = false }: Props = $props();
|
||||||
|
|
||||||
const handleDownloadFiles = async () => {
|
const handleDownloadFiles = async () => {
|
||||||
const assets = assetMultiSelectManager.assets;
|
const assets = assetMultiSelectManager.assets;
|
||||||
|
|||||||
@@ -1,44 +1,31 @@
|
|||||||
export interface DownloadProgress {
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
progress: number;
|
|
||||||
|
export interface DownloadState {
|
||||||
|
url: string;
|
||||||
|
assetIds: string[];
|
||||||
|
archiveName: string;
|
||||||
total: number;
|
total: number;
|
||||||
percentage: number;
|
downloaded: boolean;
|
||||||
abort: AbortController | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class DownloadManager {
|
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) {
|
add(key: string, url: string, assetIds: string[], archiveName: string, total: number) {
|
||||||
if (value === null) {
|
this.assets.set(key, { url, assetIds, archiveName, total, downloaded: false });
|
||||||
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, total: number, abort?: AbortController) {
|
clearAll() {
|
||||||
this.#update(key, { total, abort });
|
this.assets.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(key: string) {
|
markDownloaded(key: string) {
|
||||||
this.#update(key, null);
|
const state = this.assets.get(key);
|
||||||
}
|
if (state) {
|
||||||
|
this.assets.set(key, { ...state, downloaded: true });
|
||||||
update(key: string, progress: number, total?: number) {
|
|
||||||
const download: Partial<DownloadProgress> = { progress };
|
|
||||||
if (total !== undefined) {
|
|
||||||
download.total = total;
|
|
||||||
}
|
}
|
||||||
this.#update(key, download);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -284,5 +284,5 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handleDownloadAlbum = async (album: AlbumResponseDto) => {
|
export const handleDownloadAlbum = async (album: AlbumResponseDto) => {
|
||||||
await downloadArchive(`${album.albumName}.zip`, { albumId: album.id });
|
await downloadArchive(album.albumName, { albumId: album.id });
|
||||||
};
|
};
|
||||||
|
|||||||
+26
-4
@@ -24,7 +24,6 @@ import { init, register, t } from 'svelte-i18n';
|
|||||||
import { derived, get } from 'svelte/store';
|
import { derived, get } from 'svelte/store';
|
||||||
import { defaultLang, locales } from '$lib/constants';
|
import { defaultLang, locales } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
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 { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
||||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
@@ -293,15 +292,38 @@ export const downloadUrl = (url: string, filename: string) => {
|
|||||||
URL.revokeObjectURL(url);
|
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 downloadBlob = (data: Blob, filename: string) => downloadUrl(URL.createObjectURL(data), filename);
|
||||||
|
|
||||||
export const downloadJson = (data: unknown, filename: string) => {
|
export const downloadJson = (data: unknown, filename: string) => {
|
||||||
const blob = new Blob([JSON.stringify(data, jsonReplacer, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data, jsonReplacer, 2)], { type: 'application/json' });
|
||||||
const downloadKey = filename;
|
const downloadKey = filename;
|
||||||
downloadManager.add(downloadKey, blob.size);
|
|
||||||
downloadManager.update(downloadKey, blob.size);
|
|
||||||
downloadBlob(blob, downloadKey);
|
downloadBlob(blob, downloadKey);
|
||||||
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
|
||||||
|
const $t = get(t);
|
||||||
|
toastManager.info($t('downloading_filename', { values: { filename } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const oauth = {
|
export const oauth = {
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ import { downloadManager } from '$lib/managers/download-manager.svelte';
|
|||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
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 { getByteUnitString } from '$lib/utils/byte-units';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
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++) {
|
for (let index = 0; index < downloadInfo.archives.length; index++) {
|
||||||
const archive = downloadInfo.archives[index];
|
const archive = downloadInfo.archives[index];
|
||||||
const suffix = downloadInfo.archives.length > 1 ? `+${index + 1}` : '';
|
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 queryParams = asQueryString(authManager.params);
|
||||||
|
|
||||||
const downloadKey =
|
const downloadKey =
|
||||||
@@ -100,27 +101,26 @@ export const downloadArchive = async (fileName: string, options: Omit<DownloadIn
|
|||||||
? `${archiveName} (${index + 1}/${downloadInfo.archives.length})`
|
? `${archiveName} (${index + 1}/${downloadInfo.archives.length})`
|
||||||
: `${archiveName} `;
|
: `${archiveName} `;
|
||||||
|
|
||||||
const abort = new AbortController();
|
const url = getBaseUrl() + '/download/archive' + (queryParams ? `?${queryParams}` : '');
|
||||||
downloadManager.add(downloadKey, archive.size, abort);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO use sdk once it supports progress events
|
if (downloadInfo.archives.length > 1) {
|
||||||
const { data } = await downloadRequest({
|
downloadManager.add(downloadKey, url, archive.assetIds, archiveName, archive.size);
|
||||||
method: 'POST',
|
} else {
|
||||||
url: getBaseUrl() + '/download/archive' + (queryParams ? `?${queryParams}` : ''),
|
downloadUrlPost(url, archive.assetIds, archiveName);
|
||||||
data: { assetIds: archive.assetIds, edited: true },
|
const $t = await getFormatter();
|
||||||
signal: abort.signal,
|
const $locale = get(locale);
|
||||||
onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded),
|
toastManager.primary(
|
||||||
});
|
$t('downloading_archive_filename_size', {
|
||||||
|
values: { size: getByteUnitString(archive.size, $locale), filename: archiveName },
|
||||||
downloadBlob(data, archiveName);
|
}),
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const $t = get(t);
|
const $t = get(t);
|
||||||
handleError(error, $t('errors.unable_to_download_files'));
|
handleError(error, $t('errors.unable_to_download_files'));
|
||||||
downloadManager.clear(downloadKey);
|
|
||||||
return;
|
return;
|
||||||
} finally {
|
|
||||||
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -473,7 +473,7 @@
|
|||||||
></FavoriteAction>
|
></FavoriteAction>
|
||||||
{/if}
|
{/if}
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}>
|
<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}
|
{#if assetMultiSelectManager.isAllUserOwned}
|
||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeDescription menuItem />
|
<ChangeDescription menuItem />
|
||||||
|
|||||||
+1
-1
@@ -472,7 +472,7 @@
|
|||||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||||
/>
|
/>
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
<DownloadAction menuItem filename={person.name || 'immich'} />
|
||||||
<MenuOption
|
<MenuOption
|
||||||
icon={mdiAccountMultipleCheckOutline}
|
icon={mdiAccountMultipleCheckOutline}
|
||||||
text={$t('fix_incorrect_match')}
|
text={$t('fix_incorrect_match')}
|
||||||
|
|||||||
@@ -1,55 +1,52 @@
|
|||||||
<script lang="ts">
|
<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 { locale } from '$lib/stores/preferences.store';
|
||||||
import { Heading, IconButton } from '@immich/ui';
|
import { CloseButton, Heading, IconButton } from '@immich/ui';
|
||||||
import { mdiClose } from '@mdi/js';
|
import { mdiReload, mdiDownload } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fly, slide } from 'svelte/transition';
|
import { fly, slide } from 'svelte/transition';
|
||||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||||
|
import { downloadUrlPost } from '$lib/utils';
|
||||||
|
|
||||||
const abort = (downloadKey: string, download: DownloadProgress) => {
|
const startDownload = (downloadKey: string, download: DownloadState) => {
|
||||||
download.abort?.abort();
|
downloadUrlPost(download.url, download.assetIds, download.archiveName);
|
||||||
downloadManager.clear(downloadKey);
|
downloadManager.markDownloaded(downloadKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePanel = () => {
|
||||||
|
downloadManager.clearAll();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if downloadManager.isDownloading}
|
{#if downloadManager.isDownloading}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ x: -100, duration: 350 }}
|
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">
|
<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)}
|
{#each downloadManager.assets as [downloadKey, download] (downloadKey)}
|
||||||
{@const download = downloadManager.assets[downloadKey]}
|
<div class="mb-2 flex place-items-center gap-2" transition:slide>
|
||||||
<div class="mb-2 flex place-items-center" transition:slide>
|
<div class="min-w-0 grow">
|
||||||
<div class="w-full pe-10">
|
|
||||||
<div class="flex place-items-center justify-between gap-2 text-xs font-medium">
|
<div class="flex place-items-center justify-between gap-2 text-xs font-medium">
|
||||||
<p class="truncate">{downloadKey}</p>
|
<p class="truncate">{downloadKey}</p>
|
||||||
{#if download.total}
|
{#if download.total}
|
||||||
<p class="whitespace-nowrap">{getByteUnitString(download.total, $locale)}</p>
|
<p class="whitespace-nowrap">{getByteUnitString(download.total, $locale)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
<div class="absolute inset-e-4">
|
<div class="w-8">
|
||||||
<IconButton
|
<IconButton
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
shape="round"
|
shape="round"
|
||||||
aria-label={$t('close')}
|
aria-label={$t(download.downloaded ? 'retry' : 'download')}
|
||||||
onclick={() => abort(downloadKey, download)}
|
onclick={() => startDownload(downloadKey, download)}
|
||||||
size="tiny"
|
size="tiny"
|
||||||
icon={mdiClose}
|
icon={download.downloaded ? mdiReload : mdiDownload}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user