Merge bb0ee28486 into 5ade152bc5
commit
ff533a069a
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -351,6 +351,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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue