Diogo Correia 2025-12-10 16:09:51 +07:00 committed by GitHub
commit 9dd5e84496
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 449 additions and 30 deletions

@ -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.",
@ -18408,6 +18558,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": {

@ -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;
@ -2827,6 +2835,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
*/
@ -2847,6 +2873,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
*/

@ -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<PrepareDownloadResponseDto> {
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<StreamableFile> {
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<StreamableFile> {
return this.service.downloadRequestArchive(auth, id).then(asStreamableFile);
}
}

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

@ -582,6 +582,8 @@ export enum JobName {
DatabaseBackup = 'DatabaseBackup',
DownloadRequestCleanup = 'DownloadRequestCleanup',
FacialRecognitionQueueAll = 'FacialRecognitionQueueAll',
FacialRecognition = 'FacialRecognition',

@ -0,0 +1,69 @@
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';
import { DownloadRequestTable } from 'src/schema/tables/download-request.table';
@Injectable()
export class DownloadRequestRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
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((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
.coalesce(eb.fn.jsonAgg('download_request_asset.assetId'), sql`'[]'`)
.$castTo<string[]>()
.as('assetIds'),
)
.groupBy('download_request.id')
.executeTakeFirstOrThrow();
}
async create(entity: Insertable<DownloadRequestTable> & { 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<void> {
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();
}
}

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

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

@ -0,0 +1,23 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await sql`DROP TABLE "download_request_asset";`.execute(db);
await sql`DROP TABLE "download_request";`.execute(db);
}

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

@ -0,0 +1,10 @@
import { Column, Generated, PrimaryGeneratedColumn, Table, Timestamp } from 'src/sql-tools';
@Table('download_request')
export class DownloadRequestTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ type: 'timestamp with time zone' })
expiresAt!: Timestamp;
}

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

@ -1,10 +1,17 @@
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 { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
import { Permission } from 'src/enum';
import {
DownloadArchiveInfo,
DownloadInfoDto,
DownloadResponseDto,
PrepareDownloadResponseDto,
} from 'src/dtos/download.dto';
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';
@ -12,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<JobStatus> {
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<DownloadResponseDto> {
let assets;
@ -80,6 +96,19 @@ export class DownloadService extends BaseService {
return { totalSize, archives };
}
async prepareDownload(auth: AuthDto, dto: DownloadInfoDto): Promise<PrepareDownloadResponseDto> {
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<ImmichReadStream> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: dto.assetIds });
@ -118,4 +147,10 @@ export class DownloadService extends BaseService {
return { stream: zip.stream };
}
async downloadRequestArchive(auth: AuthDto, downloadRequestId: string): Promise<ImmichReadStream> {
const downloadRequest = await this.downloadRequestRepository.get(downloadRequestId);
const dto = { assetIds: downloadRequest.assetIds };
return this.downloadArchive(auth, dto);
}
}

@ -272,6 +272,7 @@ export class QueueService extends BaseService {
{ name: JobName.SessionCleanup },
{ name: JobName.AuditTableCleanup },
{ name: JobName.AuditLogCleanup },
{ name: JobName.DownloadRequestCleanup },
);
}

@ -356,6 +356,7 @@ export type JobItem =
// Cleanup
| { name: JobName.AuditLogCleanup; data?: IBaseJob }
| { name: JobName.DownloadRequestCleanup; data?: IBaseJob }
| { name: JobName.SessionCleanup; data?: IBaseJob }
// Tags

@ -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<DownloadInfoDto, 'archiveSize'>) => {
const $t = get(t);
const $preferences = get<UserPreferencesResponseDto | undefined>(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<DownloadIn
const archiveName = fileName.replace('.zip', `${suffix}-${DateTime.now().toFormat('yyyyLLdd_HHmmss')}.zip`);
const queryParams = asQueryString(authManager.params);
let downloadKey = `${archiveName} `;
if (downloadInfo.archives.length > 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);
}
}
};