diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4b62ee7877..b32403edab 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -210,6 +210,7 @@ Class | Method | HTTP request | Description *QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue *QueuesApi* | [**getQueueJobs**](doc//QueuesApi.md#getqueuejobs) | **GET** /queues/{name}/jobs | Retrieve queue jobs *QueuesApi* | [**getQueues**](doc//QueuesApi.md#getqueues) | **GET** /queues | List all queues +*QueuesApi* | [**queueJob**](doc//QueuesApi.md#queuejob) | **POST** /queues/job | Create a manual job *QueuesApi* | [**updateQueue**](doc//QueuesApi.md#updatequeue) | **PUT** /queues/{name} | Update a queue *SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | Retrieve assets by city *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | Retrieve explore data @@ -495,6 +496,7 @@ Class | Method | HTTP request | Description - [QueueCommand](doc//QueueCommand.md) - [QueueCommandDto](doc//QueueCommandDto.md) - [QueueDeleteDto](doc//QueueDeleteDto.md) + - [QueueJobCreateDto](doc//QueueJobCreateDto.md) - [QueueJobResponseDto](doc//QueueJobResponseDto.md) - [QueueJobStatus](doc//QueueJobStatus.md) - [QueueName](doc//QueueName.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 50120d96a6..bbdc4b0463 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -241,6 +241,7 @@ part 'model/purchase_update.dart'; part 'model/queue_command.dart'; part 'model/queue_command_dto.dart'; part 'model/queue_delete_dto.dart'; +part 'model/queue_job_create_dto.dart'; part 'model/queue_job_response_dto.dart'; part 'model/queue_job_status.dart'; part 'model/queue_name.dart'; diff --git a/mobile/openapi/lib/api/queues_api.dart b/mobile/openapi/lib/api/queues_api.dart index 50575ed706..d72035e76f 100644 --- a/mobile/openapi/lib/api/queues_api.dart +++ b/mobile/openapi/lib/api/queues_api.dart @@ -245,6 +245,54 @@ class QueuesApi { return null; } + /// Create a manual job + /// + /// Run a specific job. Most jobs are queued automatically, but this endpoint allows for manual creation of a handful of jobs, including various cleanup tasks, as well as creating a new database backup. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [QueueJobCreateDto] queueJobCreateDto (required): + Future queueJobWithHttpInfo(QueueJobCreateDto queueJobCreateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/queues/job'; + + // ignore: prefer_final_locals + Object? postBody = queueJobCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Create a manual job + /// + /// Run a specific job. Most jobs are queued automatically, but this endpoint allows for manual creation of a handful of jobs, including various cleanup tasks, as well as creating a new database backup. + /// + /// Parameters: + /// + /// * [QueueJobCreateDto] queueJobCreateDto (required): + Future queueJob(QueueJobCreateDto queueJobCreateDto,) async { + final response = await queueJobWithHttpInfo(queueJobCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Update a queue /// /// Change the paused status of a specific queue. diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9d13b3e034..ffd77e0dcd 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -530,6 +530,8 @@ class ApiClient { return QueueCommandDto.fromJson(value); case 'QueueDeleteDto': return QueueDeleteDto.fromJson(value); + case 'QueueJobCreateDto': + return QueueJobCreateDto.fromJson(value); case 'QueueJobResponseDto': return QueueJobResponseDto.fromJson(value); case 'QueueJobStatus': diff --git a/mobile/openapi/lib/model/queue_job_create_dto.dart b/mobile/openapi/lib/model/queue_job_create_dto.dart new file mode 100644 index 0000000000..b26303471e --- /dev/null +++ b/mobile/openapi/lib/model/queue_job_create_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class QueueJobCreateDto { + /// Returns a new [QueueJobCreateDto] instance. + QueueJobCreateDto({ + required this.job, + }); + + Object job; + + @override + bool operator ==(Object other) => identical(this, other) || other is QueueJobCreateDto && + other.job == job; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (job.hashCode); + + @override + String toString() => 'QueueJobCreateDto[job=$job]'; + + Map toJson() { + final json = {}; + json[r'job'] = this.job; + return json; + } + + /// Returns a new [QueueJobCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static QueueJobCreateDto? fromJson(dynamic value) { + upgradeDto(value, "QueueJobCreateDto"); + if (value is Map) { + final json = value.cast(); + + return QueueJobCreateDto( + job: mapValueOfType(json, r'job')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = QueueJobCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = QueueJobCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of QueueJobCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = QueueJobCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'job', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2f160e6bed..9323258a78 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8476,6 +8476,56 @@ "x-immich-state": "Alpha" } }, + "/queues/job": { + "post": { + "description": "Run a specific job. Most jobs are queued automatically, but this endpoint allows for manual creation of a handful of jobs, including various cleanup tasks, as well as creating a new database backup.", + "operationId": "queueJob", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueJobCreateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Create a manual job", + "tags": [ + "Queues" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.5.0", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Alpha" + } + ], + "x-immich-permission": "job.create", + "x-immich-state": "Alpha" + } + }, "/queues/{name}": { "get": { "description": "Retrieves a specific queue by its name.", @@ -19135,6 +19185,17 @@ }, "type": "object" }, + "QueueJobCreateDto": { + "properties": { + "job": { + "type": "object" + } + }, + "required": [ + "job" + ], + "type": "object" + }, "QueueJobResponseDto": { "properties": { "data": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 496e6906a2..be6d726af4 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1038,6 +1038,9 @@ export type QueueResponseDto = { name: QueueName; statistics: QueueStatisticsDto; }; +export type QueueJobCreateDto = { + job: object; +}; export type QueueUpdateDto = { isPaused?: boolean; }; @@ -3834,6 +3837,18 @@ export function getQueues(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * Create a manual job + */ +export function queueJob({ queueJobCreateDto }: { + queueJobCreateDto: QueueJobCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/queues/job", oazapfts.json({ + ...opts, + method: "POST", + body: queueJobCreateDto + }))); +} /** * Retrieve a queue */ diff --git a/server/src/controllers/queue.controller.ts b/server/src/controllers/queue.controller.ts index 1d8d918c5f..2d9f6e67a3 100644 --- a/server/src/controllers/queue.controller.ts +++ b/server/src/controllers/queue.controller.ts @@ -1,9 +1,10 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { QueueDeleteDto, + QueueJobCreateDto, QueueJobResponseDto, QueueJobSearchDto, QueueNameParamDto, @@ -19,6 +20,19 @@ import { QueueService } from 'src/services/queue.service'; export class QueueController { constructor(private service: QueueService) {} + @Post('job') + @Authenticated({ permission: Permission.JobCreate, admin: true }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Create a manual job', + description: + 'Run a specific job. Most jobs are queued automatically, but this endpoint allows for manual creation of a handful of jobs, including various cleanup tasks, as well as creating a new database backup.', + history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'), + }) + queueJob(@Body() dto: QueueJobCreateDto): Promise { + return this.service.createJob(dto); + } + @Get() @Authenticated({ permission: Permission.QueueRead, admin: true }) @Endpoint({ diff --git a/server/src/dtos/queue.dto.ts b/server/src/dtos/queue.dto.ts index 38a4a4ac6b..90e5523668 100644 --- a/server/src/dtos/queue.dto.ts +++ b/server/src/dtos/queue.dto.ts @@ -1,7 +1,75 @@ import { ApiProperty } from '@nestjs/swagger'; +import { ClassConstructor, Transform, Type } from 'class-transformer'; +import { Equals, IsBoolean, IsDefined, IsOptional, ValidateNested } from 'class-validator'; import { HistoryBuilder, Property } from 'src/decorators'; import { JobName, QueueCommand, QueueJobStatus, QueueName } from 'src/enum'; -import { ValidateBoolean, ValidateEnum } from 'src/validation'; +import { transformToOneOf, ValidateBoolean, ValidateEnum } from 'src/validation'; + +class BaseJobData { + @IsOptional() + @IsBoolean() + force?: boolean; +} + +class BaseJob { + @ValidateNested() + @Type(() => BaseJobData) + data!: BaseJobData; +} + +class JobTagCleanup extends BaseJob { + @Equals(JobName.TagCleanup) + name!: JobName.TagCleanup; +} + +class JobPersonCleanup extends BaseJob { + @Equals(JobName.PersonCleanup) + name!: JobName.PersonCleanup; +} + +class JobUserDeleteCheck extends BaseJob { + @Equals(JobName.UserDeleteCheck) + name!: JobName.UserDeleteCheck; +} + +class JobMemoryCleanup extends BaseJob { + @Equals(JobName.MemoryCleanup) + name!: JobName.MemoryCleanup; +} + +class JobMemoryGenerate extends BaseJob { + @Equals(JobName.MemoryGenerate) + name!: JobName.MemoryGenerate; +} + +class JobDatabaseBackup extends BaseJob { + @Equals(JobName.DatabaseBackup) + name!: JobName.DatabaseBackup; +} + +const JOB_MAP: Record> = { + [JobName.TagCleanup]: JobTagCleanup, + [JobName.PersonCleanup]: JobPersonCleanup, + [JobName.UserDeleteCheck]: JobUserDeleteCheck, + [JobName.MemoryCleanup]: JobMemoryCleanup, + [JobName.MemoryGenerate]: JobMemoryGenerate, + [JobName.DatabaseBackup]: JobDatabaseBackup, +}; + +export class QueueJobCreateDto { + @ValidateNested() + @Transform(transformToOneOf(JOB_MAP)) + @IsDefined({ + message: `job.name must be one of ${Object.keys(JOB_MAP)}`, + }) + job!: + | JobTagCleanup + | JobPersonCleanup + | JobUserDeleteCheck + | JobMemoryCleanup + | JobMemoryGenerate + | JobDatabaseBackup; +} export class QueueNameParamDto { @ValidateEnum({ enum: QueueName, name: 'QueueName' }) diff --git a/server/src/services/queue.service.ts b/server/src/services/queue.service.ts index cdfa2ad2ed..1c941c1e59 100644 --- a/server/src/services/queue.service.ts +++ b/server/src/services/queue.service.ts @@ -12,6 +12,7 @@ import { import { QueueCommandDto, QueueDeleteDto, + QueueJobCreateDto, QueueJobResponseDto, QueueJobSearchDto, QueueResponseDto, @@ -100,6 +101,10 @@ export class QueueService extends BaseService { this.services = services; } + createJob(dto: QueueJobCreateDto): Promise { + return this.jobRepository.queue(dto.job); + } + async runCommandLegacy(name: QueueName, dto: QueueCommandDto): Promise { this.logger.debug(`Handling command: queue=${name},command=${dto.command},force=${dto.force}`); diff --git a/server/src/validation.ts b/server/src/validation.ts index 1ac21020c5..5634a6f661 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -7,7 +7,7 @@ import { applyDecorators, } from '@nestjs/common'; import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; +import { ClassConstructor, Transform, TransformFnParams, plainToInstance } from 'class-transformer'; import { IsArray, IsBoolean, @@ -417,3 +417,8 @@ export function IsIPRange(options: IsIPRangeOptions, validationOptions?: Validat validationOptions, ); } + +export const transformToOneOf = + >>(map: T) => + ({ value }: TransformFnParams) => + value && typeof value === 'object' && map[value.name] ? plainToInstance(map[value.name], value) : null;