Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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": {
Expand Down
28 changes: 28 additions & 0 deletions open-api/typescript-sdk/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down
31 changes: 29 additions & 2 deletions server/src/controllers/download.controller.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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);
}
Comment on lines +30 to +40
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not fond of the endpoint URL nor the descriptions, so if anyone has suggestions I'd appreciate them


@Post('archive')
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
@FileResponse()
Expand All @@ -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);
}
Comment on lines +55 to +68
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

}
12 changes: 12 additions & 0 deletions server/src/dtos/download.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
58 changes: 58 additions & 0 deletions server/src/repositories/download-request.repository.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I merge this into download.repository.ts instead? They have quite different purposes

Perhaps download-request is not the best name either?

Original file line number Diff line number Diff line change
@@ -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<DB>) {}

@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<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();
}
}
2 changes: 2 additions & 0 deletions server/src/repositories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -65,6 +66,7 @@ export const repositories = [
CryptoRepository,
DatabaseRepository,
DownloadRepository,
DownloadRequestRepository,
DuplicateRepository,
EmailRepository,
EventRepository,
Expand Down
Loading
Loading