Merge bb0ee28486 into 5ade152bc5
commit
ff533a069a
|
|
@ -4448,6 +4448,83 @@
|
||||||
"x-immich-state": "Stable"
|
"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": {
|
"/download/info": {
|
||||||
"post": {
|
"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.",
|
"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"
|
"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": {
|
"/duplicates": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"description": "Delete multiple duplicate assets specified by their IDs.",
|
"description": "Delete multiple duplicate assets specified by their IDs.",
|
||||||
|
|
@ -18408,6 +18558,39 @@
|
||||||
],
|
],
|
||||||
"type": "string"
|
"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": {
|
"PurchaseResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"hideBuyButtonUntil": {
|
"hideBuyButtonUntil": {
|
||||||
|
|
|
||||||
|
|
@ -667,6 +667,14 @@ export type DownloadResponseDto = {
|
||||||
archives: DownloadArchiveInfo[];
|
archives: DownloadArchiveInfo[];
|
||||||
totalSize: number;
|
totalSize: number;
|
||||||
};
|
};
|
||||||
|
export type PrepareDownloadArchiveInfo = {
|
||||||
|
downloadRequestId: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
export type PrepareDownloadResponseDto = {
|
||||||
|
archives: PrepareDownloadArchiveInfo[];
|
||||||
|
totalSize: number;
|
||||||
|
};
|
||||||
export type DuplicateResponseDto = {
|
export type DuplicateResponseDto = {
|
||||||
assets: AssetResponseDto[];
|
assets: AssetResponseDto[];
|
||||||
duplicateId: string;
|
duplicateId: string;
|
||||||
|
|
@ -2827,6 +2835,24 @@ export function downloadArchive({ key, slug, assetIdsDto }: {
|
||||||
body: 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
|
* Retrieve download information
|
||||||
*/
|
*/
|
||||||
|
|
@ -2847,6 +2873,26 @@ export function getDownloadInfo({ key, slug, downloadInfoDto }: {
|
||||||
body: 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
|
* 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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.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 { ApiTag, Permission } from 'src/enum';
|
||||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||||
import { DownloadService } from 'src/services/download.service';
|
import { DownloadService } from 'src/services/download.service';
|
||||||
import { asStreamableFile } from 'src/utils/file';
|
import { asStreamableFile } from 'src/utils/file';
|
||||||
|
import { UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
@ApiTags(ApiTag.Download)
|
@ApiTags(ApiTag.Download)
|
||||||
@Controller('download')
|
@Controller('download')
|
||||||
|
|
@ -26,6 +27,18 @@ export class DownloadController {
|
||||||
return this.service.getDownloadInfo(auth, dto);
|
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')
|
@Post('archive')
|
||||||
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
|
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
|
||||||
@FileResponse()
|
@FileResponse()
|
||||||
|
|
@ -39,4 +52,18 @@ export class DownloadController {
|
||||||
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
|
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
|
||||||
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
|
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;
|
size!: number;
|
||||||
assetIds!: string[];
|
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',
|
DatabaseBackup = 'DatabaseBackup',
|
||||||
|
|
||||||
|
DownloadRequestCleanup = 'DownloadRequestCleanup',
|
||||||
|
|
||||||
FacialRecognitionQueueAll = 'FacialRecognitionQueueAll',
|
FacialRecognitionQueueAll = 'FacialRecognitionQueueAll',
|
||||||
FacialRecognition = 'FacialRecognition',
|
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 { CronRepository } from 'src/repositories/cron.repository';
|
||||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
|
import { DownloadRequestRepository } from 'src/repositories/download-request.repository';
|
||||||
import { DownloadRepository } from 'src/repositories/download.repository';
|
import { DownloadRepository } from 'src/repositories/download.repository';
|
||||||
import { DuplicateRepository } from 'src/repositories/duplicate.repository';
|
import { DuplicateRepository } from 'src/repositories/duplicate.repository';
|
||||||
import { EmailRepository } from 'src/repositories/email.repository';
|
import { EmailRepository } from 'src/repositories/email.repository';
|
||||||
|
|
@ -65,6 +66,7 @@ export const repositories = [
|
||||||
CryptoRepository,
|
CryptoRepository,
|
||||||
DatabaseRepository,
|
DatabaseRepository,
|
||||||
DownloadRepository,
|
DownloadRepository,
|
||||||
|
DownloadRequestRepository,
|
||||||
DuplicateRepository,
|
DuplicateRepository,
|
||||||
EmailRepository,
|
EmailRepository,
|
||||||
EventRepository,
|
EventRepository,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
|
||||||
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
|
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
import { AuditTable } from 'src/schema/tables/audit.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 { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||||
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
|
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
|
||||||
import { LibraryTable } from 'src/schema/tables/library.table';
|
import { LibraryTable } from 'src/schema/tables/library.table';
|
||||||
|
|
@ -96,6 +98,8 @@ export class ImmichDatabase {
|
||||||
AssetFileTable,
|
AssetFileTable,
|
||||||
AuditTable,
|
AuditTable,
|
||||||
AssetExifTable,
|
AssetExifTable,
|
||||||
|
DownloadRequestAssetTable,
|
||||||
|
DownloadRequestTable,
|
||||||
FaceSearchTable,
|
FaceSearchTable,
|
||||||
GeodataPlacesTable,
|
GeodataPlacesTable,
|
||||||
LibraryTable,
|
LibraryTable,
|
||||||
|
|
@ -191,6 +195,9 @@ export interface DB {
|
||||||
|
|
||||||
audit: AuditTable;
|
audit: AuditTable;
|
||||||
|
|
||||||
|
download_request_asset: DownloadRequestAssetTable;
|
||||||
|
download_request: DownloadRequestTable;
|
||||||
|
|
||||||
face_search: FaceSearchTable;
|
face_search: FaceSearchTable;
|
||||||
|
|
||||||
geodata_places: GeodataPlacesTable;
|
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 { CronRepository } from 'src/repositories/cron.repository';
|
||||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
|
import { DownloadRequestRepository } from 'src/repositories/download-request.repository';
|
||||||
import { DownloadRepository } from 'src/repositories/download.repository';
|
import { DownloadRepository } from 'src/repositories/download.repository';
|
||||||
import { DuplicateRepository } from 'src/repositories/duplicate.repository';
|
import { DuplicateRepository } from 'src/repositories/duplicate.repository';
|
||||||
import { EmailRepository } from 'src/repositories/email.repository';
|
import { EmailRepository } from 'src/repositories/email.repository';
|
||||||
|
|
@ -76,6 +77,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
||||||
CryptoRepository,
|
CryptoRepository,
|
||||||
DatabaseRepository,
|
DatabaseRepository,
|
||||||
DownloadRepository,
|
DownloadRepository,
|
||||||
|
DownloadRequestRepository,
|
||||||
DuplicateRepository,
|
DuplicateRepository,
|
||||||
EmailRepository,
|
EmailRepository,
|
||||||
EventRepository,
|
EventRepository,
|
||||||
|
|
@ -134,6 +136,7 @@ export class BaseService {
|
||||||
protected cryptoRepository: CryptoRepository,
|
protected cryptoRepository: CryptoRepository,
|
||||||
protected databaseRepository: DatabaseRepository,
|
protected databaseRepository: DatabaseRepository,
|
||||||
protected downloadRepository: DownloadRepository,
|
protected downloadRepository: DownloadRepository,
|
||||||
|
protected downloadRequestRepository: DownloadRequestRepository,
|
||||||
protected duplicateRepository: DuplicateRepository,
|
protected duplicateRepository: DuplicateRepository,
|
||||||
protected emailRepository: EmailRepository,
|
protected emailRepository: EmailRepository,
|
||||||
protected eventRepository: EventRepository,
|
protected eventRepository: EventRepository,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import { parse } from 'node:path';
|
import { parse } from 'node:path';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { OnJob } from 'src/decorators';
|
||||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
import {
|
||||||
import { Permission } from 'src/enum';
|
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 { ImmichReadStream } from 'src/repositories/storage.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { HumanReadableSize } from 'src/utils/bytes';
|
import { HumanReadableSize } from 'src/utils/bytes';
|
||||||
|
|
@ -12,6 +19,15 @@ import { getPreferences } from 'src/utils/preferences';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DownloadService extends BaseService {
|
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> {
|
async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {
|
||||||
let assets;
|
let assets;
|
||||||
|
|
||||||
|
|
@ -80,6 +96,19 @@ export class DownloadService extends BaseService {
|
||||||
return { totalSize, archives };
|
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> {
|
async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise<ImmichReadStream> {
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: dto.assetIds });
|
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: dto.assetIds });
|
||||||
|
|
||||||
|
|
@ -118,4 +147,10 @@ export class DownloadService extends BaseService {
|
||||||
|
|
||||||
return { stream: zip.stream };
|
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.SessionCleanup },
|
||||||
{ name: JobName.AuditTableCleanup },
|
{ name: JobName.AuditTableCleanup },
|
||||||
{ name: JobName.AuditLogCleanup },
|
{ name: JobName.AuditLogCleanup },
|
||||||
|
{ name: JobName.DownloadRequestCleanup },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,7 @@ export type JobItem =
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
| { name: JobName.AuditLogCleanup; data?: IBaseJob }
|
| { name: JobName.AuditLogCleanup; data?: IBaseJob }
|
||||||
|
| { name: JobName.DownloadRequestCleanup; data?: IBaseJob }
|
||||||
| { name: JobName.SessionCleanup; data?: IBaseJob }
|
| { name: JobName.SessionCleanup; data?: IBaseJob }
|
||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@ import { goto } from '$app/navigation';
|
||||||
import ToastAction from '$lib/components/ToastAction.svelte';
|
import ToastAction from '$lib/components/ToastAction.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } 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 { 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 { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
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 { 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';
|
||||||
|
|
@ -25,8 +24,8 @@ import {
|
||||||
deleteStacks,
|
deleteStacks,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
getBaseUrl,
|
getBaseUrl,
|
||||||
getDownloadInfo,
|
|
||||||
getStack,
|
getStack,
|
||||||
|
prepareDownload,
|
||||||
untagAssets,
|
untagAssets,
|
||||||
updateAsset,
|
updateAsset,
|
||||||
updateAssets,
|
updateAssets,
|
||||||
|
|
@ -182,12 +181,12 @@ export const downloadUrl = (url: string, filename: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const downloadArchive = async (fileName: string, options: Omit<DownloadInfoDto, 'archiveSize'>) => {
|
export const downloadArchive = async (fileName: string, options: Omit<DownloadInfoDto, 'archiveSize'>) => {
|
||||||
|
const $t = get(t);
|
||||||
const $preferences = get<UserPreferencesResponseDto | undefined>(preferences);
|
const $preferences = get<UserPreferencesResponseDto | undefined>(preferences);
|
||||||
const dto = { ...options, archiveSize: $preferences?.download.archiveSize };
|
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) {
|
if (error) {
|
||||||
const $t = get(t);
|
|
||||||
handleError(error, $t('errors.unable_to_download_files'));
|
handleError(error, $t('errors.unable_to_download_files'));
|
||||||
return;
|
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 archiveName = fileName.replace('.zip', `${suffix}-${DateTime.now().toFormat('yyyyLLdd_HHmmss')}.zip`);
|
||||||
const queryParams = asQueryString(authManager.params);
|
const queryParams = asQueryString(authManager.params);
|
||||||
|
|
||||||
let downloadKey = `${archiveName} `;
|
if (index !== 0) {
|
||||||
if (downloadInfo.archives.length > 1) {
|
// play nice with Safari
|
||||||
downloadKey = `${archiveName} (${index + 1}/${downloadInfo.archives.length})`;
|
await sleep(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const abort = new AbortController();
|
|
||||||
downloadManager.add(downloadKey, archive.size, abort);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO use sdk once it supports progress events
|
toastManager.success($t('downloading'));
|
||||||
const { data } = await downloadRequest({
|
downloadUrl(
|
||||||
method: 'POST',
|
getBaseUrl() + `/download/archive/${archive.downloadRequestId}` + (queryParams ? `?${queryParams}` : ''),
|
||||||
url: getBaseUrl() + '/download/archive' + (queryParams ? `?${queryParams}` : ''),
|
archiveName,
|
||||||
data: { assetIds: archive.assetIds },
|
);
|
||||||
signal: abort.signal,
|
|
||||||
onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded),
|
|
||||||
});
|
|
||||||
|
|
||||||
downloadBlob(data, archiveName);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const $t = get(t);
|
|
||||||
handleError(error, $t('errors.unable_to_download_files'));
|
handleError(error, $t('errors.unable_to_download_files'));
|
||||||
downloadManager.clear(downloadKey);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue