From 68b10f4985151789b6552e9cc065996541aebf80 Mon Sep 17 00:00:00 2001 From: Diogo Correia Date: Sun, 23 Nov 2025 19:40:01 +0000 Subject: [PATCH 1/3] feat: use browser download manager for archive downloads Fixes #14725 --- open-api/immich-openapi-specs.json | 183 ++++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 28 +++ server/src/controllers/download.controller.ts | 31 ++- server/src/dtos/download.dto.ts | 12 ++ .../download-request.repository.ts | 58 ++++++ server/src/repositories/index.ts | 2 + server/src/schema/index.ts | 7 + .../1763915568967-AddDownloadRequestTables.ts | 23 +++ .../tables/download-request-asset.table.ts | 12 ++ .../schema/tables/download-request.table.ts | 10 + server/src/services/base.service.ts | 3 + server/src/services/download.service.ts | 27 ++- web/src/lib/utils/asset-utils.ts | 38 ++-- 13 files changed, 405 insertions(+), 29 deletions(-) create mode 100644 server/src/repositories/download-request.repository.ts create mode 100644 server/src/schema/migrations/1763915568967-AddDownloadRequestTables.ts create mode 100644 server/src/schema/tables/download-request-asset.table.ts create mode 100644 server/src/schema/tables/download-request.table.ts diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index ffaad85906..5ec27380f7 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4448,6 +4448,83 @@ "x-immich-state": "Stable" } }, + "/download/archive/{id}": { + "get": { + "description": "Download a ZIP archive corresponding to the given download request. The download request needs to be created first.", + "operationId": "downloadRequestArchive", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Download asset archive from download request", + "tags": [ + "Download" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-permission": "asset.download", + "x-immich-state": "Stable" + } + }, "/download/info": { "post": { "description": "Retrieve information about how to request a download for the specified assets or album. The response includes groups of assets that can be downloaded together.", @@ -4525,6 +4602,79 @@ "x-immich-state": "Stable" } }, + "/download/request": { + "post": { + "description": "Create a download request for the specified assets or album. The response includes one or more tokens that can be used to download groups of assets.", + "operationId": "prepareDownload", + "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownloadInfoDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrepareDownloadResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Prepare download archive", + "tags": [ + "Download" + ], + "x-immich-history": [ + { + "version": "v2", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-permission": "asset.download", + "x-immich-state": "Stable" + } + }, "/duplicates": { "delete": { "description": "Delete multiple duplicate assets specified by their IDs.", @@ -18029,6 +18179,39 @@ ], "type": "string" }, + "PrepareDownloadArchiveInfo": { + "properties": { + "downloadRequestId": { + "type": "string" + }, + "size": { + "type": "integer" + } + }, + "required": [ + "downloadRequestId", + "size" + ], + "type": "object" + }, + "PrepareDownloadResponseDto": { + "properties": { + "archives": { + "items": { + "$ref": "#/components/schemas/PrepareDownloadArchiveInfo" + }, + "type": "array" + }, + "totalSize": { + "type": "integer" + } + }, + "required": [ + "archives", + "totalSize" + ], + "type": "object" + }, "PurchaseResponse": { "properties": { "hideBuyButtonUntil": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 0de0e7696b..4ba1d86373 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -667,6 +667,14 @@ export type DownloadResponseDto = { archives: DownloadArchiveInfo[]; totalSize: number; }; +export type PrepareDownloadArchiveInfo = { + downloadRequestId: string; + size: number; +}; +export type PrepareDownloadResponseDto = { + archives: PrepareDownloadArchiveInfo[]; + totalSize: number; +}; export type DuplicateResponseDto = { assets: AssetResponseDto[]; duplicateId: string; @@ -2829,6 +2837,26 @@ export function getDownloadInfo({ key, slug, downloadInfoDto }: { body: downloadInfoDto }))); } +/** + * Prepare download archive + */ +export function prepareDownload({ key, slug, downloadInfoDto }: { + key?: string; + slug?: string; + downloadInfoDto: DownloadInfoDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: PrepareDownloadResponseDto; + }>(`/download/request${QS.query(QS.explode({ + key, + slug + }))}`, oazapfts.json({ + ...opts, + method: "POST", + body: downloadInfoDto + }))); +} /** * Delete duplicates */ diff --git a/server/src/controllers/download.controller.ts b/server/src/controllers/download.controller.ts index 942d44f4c3..cf06045f91 100644 --- a/server/src/controllers/download.controller.ts +++ b/server/src/controllers/download.controller.ts @@ -1,13 +1,14 @@ -import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, StreamableFile } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; +import { DownloadInfoDto, DownloadResponseDto, PrepareDownloadResponseDto } from 'src/dtos/download.dto'; import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { DownloadService } from 'src/services/download.service'; import { asStreamableFile } from 'src/utils/file'; +import { UUIDParamDto } from 'src/validation'; @ApiTags(ApiTag.Download) @Controller('download') @@ -26,6 +27,18 @@ export class DownloadController { return this.service.getDownloadInfo(auth, dto); } + @Post('request') + @Authenticated({ permission: Permission.AssetDownload, sharedLink: true }) + @Endpoint({ + summary: 'Prepare download archive', + description: + 'Create a download request for the specified assets or album. The response includes one or more tokens that can be used to download groups of assets.', + history: new HistoryBuilder().added('v2').stable('v2'), + }) + prepareDownload(@Auth() auth: AuthDto, @Body() dto: DownloadInfoDto): Promise { + return this.service.prepareDownload(auth, dto); + } + @Post('archive') @Authenticated({ permission: Permission.AssetDownload, sharedLink: true }) @FileResponse() @@ -39,4 +52,18 @@ export class DownloadController { downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise { return this.service.downloadArchive(auth, dto).then(asStreamableFile); } + + @Get('archive/:id') + @Authenticated({ permission: Permission.AssetDownload, sharedLink: true }) + @FileResponse() + @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Download asset archive from download request', + description: + 'Download a ZIP archive corresponding to the given download request. The download request needs to be created first.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) + downloadRequestArchive(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.downloadRequestArchive(auth, id).then(asStreamableFile); + } } diff --git a/server/src/dtos/download.dto.ts b/server/src/dtos/download.dto.ts index e6588a9944..d01666a9bc 100644 --- a/server/src/dtos/download.dto.ts +++ b/server/src/dtos/download.dto.ts @@ -30,3 +30,15 @@ export class DownloadArchiveInfo { size!: number; assetIds!: string[]; } + +export class PrepareDownloadResponseDto { + @ApiProperty({ type: 'integer' }) + totalSize!: number; + archives!: PrepareDownloadArchiveInfo[]; +} + +export class PrepareDownloadArchiveInfo { + @ApiProperty({ type: 'integer' }) + size!: number; + downloadRequestId!: string; +} diff --git a/server/src/repositories/download-request.repository.ts b/server/src/repositories/download-request.repository.ts new file mode 100644 index 0000000000..d08ac81a86 --- /dev/null +++ b/server/src/repositories/download-request.repository.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely, sql } from 'kysely'; +import _ from 'lodash'; +import { InjectKysely } from 'nestjs-kysely'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { DB } from 'src/schema'; +import { DownloadRequestTable } from 'src/schema/tables/download-request.table'; + +@Injectable() +export class DownloadRequestRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID] }) + get(id: string) { + return this.db + .selectFrom('download_request') + .selectAll('download_request') + .where('download_request.id', '=', id) + .leftJoin('download_request_asset', 'download_request_asset.downloadRequestId', 'download_request.id') + .select((eb) => + eb.fn + .coalesce(eb.fn.jsonAgg('download_request_asset.assetId'), sql`'[]'`) + .$castTo() + .as('assetIds'), + ) + .groupBy('download_request.id') + .executeTakeFirstOrThrow(); + } + + async create(entity: Insertable & { assetIds?: string[] }) { + const { id } = await this.db + .insertInto('download_request') + .values(_.omit(entity, 'assetIds')) + .returningAll() + .executeTakeFirstOrThrow(); + + if (entity.assetIds && entity.assetIds.length > 0) { + await this.db + .insertInto('download_request_asset') + .values(entity.assetIds!.map((assetId) => ({ assetId, downloadRequestId: id }))) + .execute(); + } + + return this.getDownloadRequest(id); + } + + async remove(id: string): Promise { + await this.db.deleteFrom('download_request').where('download_request.id', '=', id).execute(); + } + + private getDownloadRequest(id: string) { + return this.db + .selectFrom('download_request') + .selectAll('download_request') + .where('download_request.id', '=', id) + .executeTakeFirstOrThrow(); + } +} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index c59110d674..d0a01cb894 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -11,6 +11,7 @@ import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; +import { DownloadRequestRepository } from 'src/repositories/download-request.repository'; import { DownloadRepository } from 'src/repositories/download.repository'; import { DuplicateRepository } from 'src/repositories/duplicate.repository'; import { EmailRepository } from 'src/repositories/email.repository'; @@ -65,6 +66,7 @@ export const repositories = [ CryptoRepository, DatabaseRepository, DownloadRepository, + DownloadRequestRepository, DuplicateRepository, EmailRepository, EventRepository, diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 9e206826e6..113d3af3dc 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -38,6 +38,8 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { AuditTable } from 'src/schema/tables/audit.table'; +import { DownloadRequestAssetTable } from 'src/schema/tables/download-request-asset.table'; +import { DownloadRequestTable } from 'src/schema/tables/download-request.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; import { LibraryTable } from 'src/schema/tables/library.table'; @@ -96,6 +98,8 @@ export class ImmichDatabase { AssetFileTable, AuditTable, AssetExifTable, + DownloadRequestAssetTable, + DownloadRequestTable, FaceSearchTable, GeodataPlacesTable, LibraryTable, @@ -191,6 +195,9 @@ export interface DB { audit: AuditTable; + download_request_asset: DownloadRequestAssetTable; + download_request: DownloadRequestTable; + face_search: FaceSearchTable; geodata_places: GeodataPlacesTable; diff --git a/server/src/schema/migrations/1763915568967-AddDownloadRequestTables.ts b/server/src/schema/migrations/1763915568967-AddDownloadRequestTables.ts new file mode 100644 index 0000000000..d373944f72 --- /dev/null +++ b/server/src/schema/migrations/1763915568967-AddDownloadRequestTables.ts @@ -0,0 +1,23 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "download_request" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "expiresAt" timestamp with time zone NOT NULL, + CONSTRAINT "download_request_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE TABLE "download_request_asset" ( + "assetId" uuid NOT NULL, + "downloadRequestId" uuid NOT NULL, + CONSTRAINT "download_request_asset_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "download_request_asset_downloadRequestId_fkey" FOREIGN KEY ("downloadRequestId") REFERENCES "download_request" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "download_request_asset_pkey" PRIMARY KEY ("assetId", "downloadRequestId") +);`.execute(db); + await sql`CREATE INDEX "download_request_asset_assetId_idx" ON "download_request_asset" ("assetId");`.execute(db); + await sql`CREATE INDEX "download_request_asset_downloadRequestId_idx" ON "download_request_asset" ("downloadRequestId");`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE "download_request_asset";`.execute(db); + await sql`DROP TABLE "download_request";`.execute(db); +} diff --git a/server/src/schema/tables/download-request-asset.table.ts b/server/src/schema/tables/download-request-asset.table.ts new file mode 100644 index 0000000000..df1931b236 --- /dev/null +++ b/server/src/schema/tables/download-request-asset.table.ts @@ -0,0 +1,12 @@ +import { AssetTable } from 'src/schema/tables/asset.table'; +import { DownloadRequestTable } from 'src/schema/tables/download-request.table'; +import { ForeignKeyColumn, Table } from 'src/sql-tools'; + +@Table('download_request_asset') +export class DownloadRequestAssetTable { + @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) + assetId!: string; + + @ForeignKeyColumn(() => DownloadRequestTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) + downloadRequestId!: string; +} diff --git a/server/src/schema/tables/download-request.table.ts b/server/src/schema/tables/download-request.table.ts new file mode 100644 index 0000000000..62c3b236fb --- /dev/null +++ b/server/src/schema/tables/download-request.table.ts @@ -0,0 +1,10 @@ +import { Column, Generated, PrimaryGeneratedColumn, Table, Timestamp } from 'src/sql-tools'; + +@Table('download_request') +export class DownloadRequestTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @Column({ type: 'timestamp with time zone' }) + expiresAt!: Timestamp; +} diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 9c422818b3..282b740b3b 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -18,6 +18,7 @@ import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; +import { DownloadRequestRepository } from 'src/repositories/download-request.repository'; import { DownloadRepository } from 'src/repositories/download.repository'; import { DuplicateRepository } from 'src/repositories/duplicate.repository'; import { EmailRepository } from 'src/repositories/email.repository'; @@ -76,6 +77,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ CryptoRepository, DatabaseRepository, DownloadRepository, + DownloadRequestRepository, DuplicateRepository, EmailRepository, EventRepository, @@ -134,6 +136,7 @@ export class BaseService { protected cryptoRepository: CryptoRepository, protected databaseRepository: DatabaseRepository, protected downloadRepository: DownloadRepository, + protected downloadRequestRepository: DownloadRequestRepository, protected duplicateRepository: DuplicateRepository, protected emailRepository: EmailRepository, protected eventRepository: EventRepository, diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index a5f734e59c..185f242594 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -1,9 +1,15 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { DateTime } from 'luxon'; import { parse } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; +import { + DownloadArchiveInfo, + DownloadInfoDto, + DownloadResponseDto, + PrepareDownloadResponseDto, +} from 'src/dtos/download.dto'; import { Permission } from 'src/enum'; import { ImmichReadStream } from 'src/repositories/storage.repository'; import { BaseService } from 'src/services/base.service'; @@ -80,6 +86,19 @@ export class DownloadService extends BaseService { return { totalSize, archives }; } + async prepareDownload(auth: AuthDto, dto: DownloadInfoDto): Promise { + const info = await this.getDownloadInfo(auth, dto); + const expiresAt = DateTime.now().plus({ hours: 24 }).toJSDate(); + + const newArchives = []; + for (const archive of info.archives) { + const downloadRequest = await this.downloadRequestRepository.create({ expiresAt, assetIds: archive.assetIds }); + newArchives.push({ size: archive.size, downloadRequestId: downloadRequest.id }); + } + + return { totalSize: info.totalSize, archives: newArchives }; + } + async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: dto.assetIds }); @@ -118,4 +137,10 @@ export class DownloadService extends BaseService { return { stream: zip.stream }; } + + async downloadRequestArchive(auth: AuthDto, downloadRequestId: string): Promise { + const downloadRequest = await this.downloadRequestRepository.get(downloadRequestId); + const dto = { assetIds: downloadRequest.assetIds }; + return this.downloadArchive(auth, dto); + } } diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index bc853c53e4..83facb446d 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -2,14 +2,13 @@ import { goto } from '$app/navigation'; import ToastAction from '$lib/components/ToastAction.svelte'; import { AppRoute } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; -import { downloadManager } from '$lib/managers/download-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { preferences } from '$lib/stores/user.store'; -import { downloadRequest, sleep, withError } from '$lib/utils'; +import { sleep, withError } from '$lib/utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { getFormatter } from '$lib/utils/i18n'; import { navigate } from '$lib/utils/navigation'; @@ -25,8 +24,8 @@ import { deleteStacks, getAssetInfo, getBaseUrl, - getDownloadInfo, getStack, + prepareDownload, untagAssets, updateAsset, updateAssets, @@ -182,12 +181,12 @@ export const downloadUrl = (url: string, filename: string) => { }; export const downloadArchive = async (fileName: string, options: Omit) => { + const $t = get(t); const $preferences = get(preferences); const dto = { ...options, archiveSize: $preferences?.download.archiveSize }; - const [error, downloadInfo] = await withError(() => getDownloadInfo({ ...authManager.params, downloadInfoDto: dto })); + const [error, downloadInfo] = await withError(() => prepareDownload({ ...authManager.params, downloadInfoDto: dto })); if (error) { - const $t = get(t); handleError(error, $t('errors.unable_to_download_files')); return; } @@ -202,32 +201,19 @@ export const downloadArchive = async (fileName: string, options: Omit 1) { - downloadKey = `${archiveName} (${index + 1}/${downloadInfo.archives.length})`; + if (index !== 0) { + // play nice with Safari + await sleep(500); } - const abort = new AbortController(); - downloadManager.add(downloadKey, archive.size, abort); - 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 }, - signal: abort.signal, - onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded), - }); - - downloadBlob(data, archiveName); + toastManager.success($t('downloading')); + downloadUrl( + getBaseUrl() + `/download/archive/${archive.downloadRequestId}` + (queryParams ? `?${queryParams}` : ''), + archiveName, + ); } catch (error) { - const $t = get(t); handleError(error, $t('errors.unable_to_download_files')); - downloadManager.clear(downloadKey); - return; - } finally { - setTimeout(() => downloadManager.clear(downloadKey), 5000); } } }; From 642fcd12ec22605a7c197a9ed192e117ca7fd758 Mon Sep 17 00:00:00 2001 From: Diogo Correia Date: Sun, 23 Nov 2025 20:01:45 +0000 Subject: [PATCH 2/3] chore: generate missing openapi --- open-api/typescript-sdk/src/fetch-client.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 4ba1d86373..f295d53733 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2817,6 +2817,24 @@ export function downloadArchive({ key, slug, assetIdsDto }: { body: assetIdsDto }))); } +/** + * Download asset archive from download request + */ +export function downloadRequestArchive({ id, key, slug }: { + id: string; + key?: string; + slug?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/download/archive/${encodeURIComponent(id)}${QS.query(QS.explode({ + key, + slug + }))}`, { + ...opts + })); +} /** * Retrieve download information */ From bb0ee28486c127c2065f93bcc8e82157a3c56e76 Mon Sep 17 00:00:00 2001 From: Diogo Correia Date: Tue, 2 Dec 2025 22:57:01 +0000 Subject: [PATCH 3/3] feat: cleanup old download requests --- server/src/enum.ts | 2 ++ .../src/repositories/download-request.repository.ts | 13 ++++++++++++- server/src/services/download.service.ts | 12 +++++++++++- server/src/services/queue.service.ts | 1 + server/src/types.ts | 1 + 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/server/src/enum.ts b/server/src/enum.ts index d397f9d2ae..b0a40ebb25 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -564,6 +564,8 @@ export enum JobName { DatabaseBackup = 'DatabaseBackup', + DownloadRequestCleanup = 'DownloadRequestCleanup', + FacialRecognitionQueueAll = 'FacialRecognitionQueueAll', FacialRecognition = 'FacialRecognition', diff --git a/server/src/repositories/download-request.repository.ts b/server/src/repositories/download-request.repository.ts index d08ac81a86..26b23a9fde 100644 --- a/server/src/repositories/download-request.repository.ts +++ b/server/src/repositories/download-request.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, sql } from 'kysely'; import _ from 'lodash'; +import { DateTime } from 'luxon'; import { InjectKysely } from 'nestjs-kysely'; import { DummyValue, GenerateSql } from 'src/decorators'; import { DB } from 'src/schema'; @@ -10,12 +11,22 @@ import { DownloadRequestTable } from 'src/schema/tables/download-request.table'; export class DownloadRequestRepository { constructor(@InjectKysely() private db: Kysely) {} + cleanup() { + return this.db + .deleteFrom('download_request') + .where('download_request.expiresAt', '<=', DateTime.now().toJSDate()) + .returning(['id']) + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID] }) get(id: string) { return this.db .selectFrom('download_request') .selectAll('download_request') - .where('download_request.id', '=', id) + .where((eb) => + eb.and([eb('download_request.id', '=', id), eb('download_request.expiresAt', '>', DateTime.now().toJSDate())]), + ) .leftJoin('download_request_asset', 'download_request_asset.downloadRequestId', 'download_request.id') .select((eb) => eb.fn diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 185f242594..e7639cfd20 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { parse } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; +import { OnJob } from 'src/decorators'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -10,7 +11,7 @@ import { DownloadResponseDto, PrepareDownloadResponseDto, } from 'src/dtos/download.dto'; -import { Permission } from 'src/enum'; +import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { ImmichReadStream } from 'src/repositories/storage.repository'; import { BaseService } from 'src/services/base.service'; import { HumanReadableSize } from 'src/utils/bytes'; @@ -18,6 +19,15 @@ import { getPreferences } from 'src/utils/preferences'; @Injectable() export class DownloadService extends BaseService { + @OnJob({ name: JobName.DownloadRequestCleanup, queue: QueueName.BackgroundTask }) + async handleDownloadRequestCleanup(): Promise { + const requests = await this.downloadRequestRepository.cleanup(); + + this.logger.log(`Deleted ${requests.length} expired download requests`); + + return JobStatus.Success; + } + async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise { let assets; diff --git a/server/src/services/queue.service.ts b/server/src/services/queue.service.ts index bea665e8fd..c6eec0a401 100644 --- a/server/src/services/queue.service.ts +++ b/server/src/services/queue.service.ts @@ -226,6 +226,7 @@ export class QueueService extends BaseService { { name: JobName.SessionCleanup }, { name: JobName.AuditTableCleanup }, { name: JobName.AuditLogCleanup }, + { name: JobName.DownloadRequestCleanup }, ); } diff --git a/server/src/types.ts b/server/src/types.ts index dd3d25a7cb..4ef8e8ea3b 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -362,6 +362,7 @@ export type JobItem = // Cleanup | { name: JobName.AuditLogCleanup; data?: IBaseJob } + | { name: JobName.DownloadRequestCleanup; data?: IBaseJob } | { name: JobName.SessionCleanup; data?: IBaseJob } // Tags