feat(web,server): run jobs for specific assets (#3712)

* feat(web,server): manually queue asset job

* chore: open api

* chore: tests
This commit is contained in:
Jason Rasmussen 2023-08-18 10:31:48 -04:00 committed by GitHub
parent 66490d5db4
commit 5e901e4d21
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 506 additions and 18 deletions

View file

@ -525,6 +525,42 @@ export const AssetIdsResponseDtoErrorEnum = {
export type AssetIdsResponseDtoErrorEnum = typeof AssetIdsResponseDtoErrorEnum[keyof typeof AssetIdsResponseDtoErrorEnum]; export type AssetIdsResponseDtoErrorEnum = typeof AssetIdsResponseDtoErrorEnum[keyof typeof AssetIdsResponseDtoErrorEnum];
/**
*
* @export
* @enum {string}
*/
export const AssetJobName = {
RegenerateThumbnail: 'regenerate-thumbnail',
RefreshMetadata: 'refresh-metadata',
TranscodeVideo: 'transcode-video'
} as const;
export type AssetJobName = typeof AssetJobName[keyof typeof AssetJobName];
/**
*
* @export
* @interface AssetJobsDto
*/
export interface AssetJobsDto {
/**
*
* @type {Array<string>}
* @memberof AssetJobsDto
*/
'assetIds': Array<string>;
/**
*
* @type {AssetJobName}
* @memberof AssetJobsDto
*/
'name': AssetJobName;
}
/** /**
* *
* @export * @export
@ -5784,6 +5820,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
*
* @param {AssetJobsDto} assetJobsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
runAssetJobs: async (assetJobsDto: AssetJobsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'assetJobsDto' is not null or undefined
assertParamExists('runAssetJobs', 'assetJobsDto', assetJobsDto)
const localVarPath = `/asset/jobs`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(assetJobsDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/** /**
* *
* @param {SearchAssetDto} searchAssetDto * @param {SearchAssetDto} searchAssetDto
@ -6331,6 +6411,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/**
*
* @param {AssetJobsDto} assetJobsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async runAssetJobs(assetJobsDto: AssetJobsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.runAssetJobs(assetJobsDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* *
* @param {SearchAssetDto} searchAssetDto * @param {SearchAssetDto} searchAssetDto
@ -6584,6 +6674,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFileUploadResponseDto> { importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFileUploadResponseDto> {
return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath)); return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath));
}, },
/**
*
* @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
runAssetJobs(requestParameters: AssetApiRunAssetJobsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.runAssetJobs(requestParameters.assetJobsDto, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {AssetApiSearchAssetRequest} requestParameters Request parameters. * @param {AssetApiSearchAssetRequest} requestParameters Request parameters.
@ -7066,6 +7165,20 @@ export interface AssetApiImportFileRequest {
readonly importAssetDto: ImportAssetDto readonly importAssetDto: ImportAssetDto
} }
/**
* Request parameters for runAssetJobs operation in AssetApi.
* @export
* @interface AssetApiRunAssetJobsRequest
*/
export interface AssetApiRunAssetJobsRequest {
/**
*
* @type {AssetJobsDto}
* @memberof AssetApiRunAssetJobs
*/
readonly assetJobsDto: AssetJobsDto
}
/** /**
* Request parameters for searchAsset operation in AssetApi. * Request parameters for searchAsset operation in AssetApi.
* @export * @export
@ -7472,6 +7585,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath));
} }
/**
*
* @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public runAssetJobs(requestParameters: AssetApiRunAssetJobsRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).runAssetJobs(requestParameters.assetJobsDto, options).then((request) => request(this.axios, this.basePath));
}
/** /**
* *
* @param {AssetApiSearchAssetRequest} requestParameters Request parameters. * @param {AssetApiSearchAssetRequest} requestParameters Request parameters.

View file

@ -23,6 +23,8 @@ doc/AssetBulkUploadCheckResult.md
doc/AssetFileUploadResponseDto.md doc/AssetFileUploadResponseDto.md
doc/AssetIdsDto.md doc/AssetIdsDto.md
doc/AssetIdsResponseDto.md doc/AssetIdsResponseDto.md
doc/AssetJobName.md
doc/AssetJobsDto.md
doc/AssetResponseDto.md doc/AssetResponseDto.md
doc/AssetStatsResponseDto.md doc/AssetStatsResponseDto.md
doc/AssetTypeEnum.md doc/AssetTypeEnum.md
@ -168,6 +170,8 @@ lib/model/asset_bulk_upload_check_result.dart
lib/model/asset_file_upload_response_dto.dart lib/model/asset_file_upload_response_dto.dart
lib/model/asset_ids_dto.dart lib/model/asset_ids_dto.dart
lib/model/asset_ids_response_dto.dart lib/model/asset_ids_response_dto.dart
lib/model/asset_job_name.dart
lib/model/asset_jobs_dto.dart
lib/model/asset_response_dto.dart lib/model/asset_response_dto.dart
lib/model/asset_stats_response_dto.dart lib/model/asset_stats_response_dto.dart
lib/model/asset_type_enum.dart lib/model/asset_type_enum.dart
@ -282,6 +286,8 @@ test/asset_bulk_upload_check_result_test.dart
test/asset_file_upload_response_dto_test.dart test/asset_file_upload_response_dto_test.dart
test/asset_ids_dto_test.dart test/asset_ids_dto_test.dart
test/asset_ids_response_dto_test.dart test/asset_ids_response_dto_test.dart
test/asset_job_name_test.dart
test/asset_jobs_dto_test.dart
test/asset_response_dto_test.dart test/asset_response_dto_test.dart
test/asset_stats_response_dto_test.dart test/asset_stats_response_dto_test.dart
test/asset_type_enum_test.dart test/asset_type_enum_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/AssetJobName.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/AssetJobsDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1367,6 +1367,41 @@
] ]
} }
}, },
"/asset/jobs": {
"post": {
"operationId": "runAssetJobs",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetJobsDto"
}
}
},
"required": true
},
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Asset"
]
}
},
"/asset/map-marker": { "/asset/map-marker": {
"get": { "get": {
"operationId": "getMapMarkers", "operationId": "getMapMarkers",
@ -5042,6 +5077,33 @@
], ],
"type": "object" "type": "object"
}, },
"AssetJobName": {
"enum": [
"regenerate-thumbnail",
"refresh-metadata",
"transcode-video"
],
"type": "string"
},
"AssetJobsDto": {
"properties": {
"assetIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"name": {
"$ref": "#/components/schemas/AssetJobName"
}
},
"required": [
"assetIds",
"name"
],
"type": "object"
},
"AssetResponseDto": { "AssetResponseDto": {
"properties": { "properties": {
"checksum": { "checksum": {

View file

@ -7,15 +7,17 @@ import {
newAccessRepositoryMock, newAccessRepositoryMock,
newAssetRepositoryMock, newAssetRepositoryMock,
newCryptoRepositoryMock, newCryptoRepositoryMock,
newJobRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
} from '@test'; } from '@test';
import { when } from 'jest-when'; import { when } from 'jest-when';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { ICryptoRepository } from '../crypto'; import { ICryptoRepository } from '../crypto';
import { IJobRepository, JobName } from '../index';
import { IStorageRepository } from '../storage'; import { IStorageRepository } from '../storage';
import { AssetStats, IAssetRepository } from './asset.repository'; import { AssetStats, IAssetRepository } from './asset.repository';
import { AssetService, UploadFieldName } from './asset.service'; import { AssetService, UploadFieldName } from './asset.service';
import { AssetStatsResponseDto, DownloadResponseDto } from './dto'; import { AssetJobName, AssetStatsResponseDto, DownloadResponseDto } from './dto';
import { mapAsset } from './response-dto'; import { mapAsset } from './response-dto';
const downloadResponse: DownloadResponseDto = { const downloadResponse: DownloadResponseDto = {
@ -145,6 +147,7 @@ describe(AssetService.name, () => {
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let assetMock: jest.Mocked<IAssetRepository>; let assetMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>; let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
it('should work', () => { it('should work', () => {
@ -155,8 +158,9 @@ describe(AssetService.name, () => {
accessMock = newAccessRepositoryMock(); accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
cryptoMock = newCryptoRepositoryMock(); cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
sut = new AssetService(accessMock, assetMock, cryptoMock, storageMock); sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, storageMock);
}); });
describe('canUpload', () => { describe('canUpload', () => {
@ -532,4 +536,24 @@ describe(AssetService.name, () => {
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
}); });
}); });
describe('run', () => {
it('should run the refresh metadata job', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }),
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } });
});
it('should run the refresh thumbnails job', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }),
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } });
});
it('should run the transcode video', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }),
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
});
});
}); });

View file

@ -8,11 +8,14 @@ import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto'; import { ICryptoRepository } from '../crypto';
import { mimeTypes } from '../domain.constant'; import { mimeTypes } from '../domain.constant';
import { HumanReadableSize, usePagination } from '../domain.util'; import { HumanReadableSize, usePagination } from '../domain.util';
import { IJobRepository, JobName } from '../job';
import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage'; import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { IAssetRepository } from './asset.repository'; import { IAssetRepository } from './asset.repository';
import { import {
AssetBulkUpdateDto, AssetBulkUpdateDto,
AssetIdsDto, AssetIdsDto,
AssetJobName,
AssetJobsDto,
DownloadArchiveInfo, DownloadArchiveInfo,
DownloadInfoDto, DownloadInfoDto,
DownloadResponseDto, DownloadResponseDto,
@ -54,6 +57,7 @@ export class AssetService {
@Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
) { ) {
this.access = new AccessCore(accessRepository); this.access = new AccessCore(accessRepository);
@ -275,4 +279,24 @@ export class AssetService {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids); await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
await this.assetRepository.updateAll(ids, options); await this.assetRepository.updateAll(ids, options);
} }
async run(authUser: AuthUserDto, dto: AssetJobsDto) {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);
for (const id of dto.assetIds) {
switch (dto.name) {
case AssetJobName.REFRESH_METADATA:
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id } });
break;
case AssetJobName.REGENERATE_THUMBNAIL:
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id } });
break;
case AssetJobName.TRANSCODE_VIDEO:
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id } });
break;
}
}
}
} }

View file

@ -1,6 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum } from 'class-validator';
import { ValidateUUID } from '../../domain.util'; import { ValidateUUID } from '../../domain.util';
export class AssetIdsDto { export class AssetIdsDto {
@ValidateUUID({ each: true }) @ValidateUUID({ each: true })
assetIds!: string[]; assetIds!: string[];
} }
export enum AssetJobName {
REGENERATE_THUMBNAIL = 'regenerate-thumbnail',
REFRESH_METADATA = 'refresh-metadata',
TRANSCODE_VIDEO = 'transcode-video',
}
export class AssetJobsDto extends AssetIdsDto {
@ApiProperty({ enumName: 'AssetJobName', enum: AssetJobName })
@IsEnum(AssetJobName)
name!: AssetJobName;
}

View file

@ -1,6 +1,7 @@
import { import {
AssetBulkUpdateDto, AssetBulkUpdateDto,
AssetIdsDto, AssetIdsDto,
AssetJobsDto,
AssetResponseDto, AssetResponseDto,
AssetService, AssetService,
AssetStatsDto, AssetStatsDto,
@ -78,6 +79,12 @@ export class AssetController {
return this.service.getByTimeBucket(authUser, dto); return this.service.getByTimeBucket(authUser, dto);
} }
@Post('jobs')
@HttpCode(HttpStatus.NO_CONTENT)
runAssetJobs(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetJobsDto): Promise<void> {
return this.service.run(authUser, dto);
}
@Put() @Put()
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
updateAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkUpdateDto): Promise<void> { updateAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {

View file

@ -3,6 +3,7 @@ import {
APIKeyApi, APIKeyApi,
AssetApi, AssetApi,
AssetApiFp, AssetApiFp,
AssetJobName,
AuthenticationApi, AuthenticationApi,
Configuration, Configuration,
ConfigurationParameters, ConfigurationParameters,
@ -120,6 +121,26 @@ export class ImmichApi {
return names[jobName]; return names[jobName];
} }
public getAssetJobName(job: AssetJobName) {
const names: Record<AssetJobName, string> = {
[AssetJobName.RefreshMetadata]: 'Refresh metadata',
[AssetJobName.RegenerateThumbnail]: 'Refresh thumbnails',
[AssetJobName.TranscodeVideo]: 'Refresh encoded videos',
};
return names[job];
}
public getAssetJobMessage(job: AssetJobName) {
const messages: Record<AssetJobName, string> = {
[AssetJobName.RefreshMetadata]: 'Refreshing metadata',
[AssetJobName.RegenerateThumbnail]: `Regenerating thumbnails`,
[AssetJobName.TranscodeVideo]: `Refreshing encoded video`,
};
return messages[job];
}
} }
export const api = new ImmichApi({ basePath: '/api' }); export const api = new ImmichApi({ basePath: '/api' });

View file

@ -525,6 +525,42 @@ export const AssetIdsResponseDtoErrorEnum = {
export type AssetIdsResponseDtoErrorEnum = typeof AssetIdsResponseDtoErrorEnum[keyof typeof AssetIdsResponseDtoErrorEnum]; export type AssetIdsResponseDtoErrorEnum = typeof AssetIdsResponseDtoErrorEnum[keyof typeof AssetIdsResponseDtoErrorEnum];
/**
*
* @export
* @enum {string}
*/
export const AssetJobName = {
RegenerateThumbnail: 'regenerate-thumbnail',
RefreshMetadata: 'refresh-metadata',
TranscodeVideo: 'transcode-video'
} as const;
export type AssetJobName = typeof AssetJobName[keyof typeof AssetJobName];
/**
*
* @export
* @interface AssetJobsDto
*/
export interface AssetJobsDto {
/**
*
* @type {Array<string>}
* @memberof AssetJobsDto
*/
'assetIds': Array<string>;
/**
*
* @type {AssetJobName}
* @memberof AssetJobsDto
*/
'name': AssetJobName;
}
/** /**
* *
* @export * @export
@ -5784,6 +5820,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
*
* @param {AssetJobsDto} assetJobsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
runAssetJobs: async (assetJobsDto: AssetJobsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'assetJobsDto' is not null or undefined
assertParamExists('runAssetJobs', 'assetJobsDto', assetJobsDto)
const localVarPath = `/asset/jobs`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(assetJobsDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/** /**
* *
* @param {SearchAssetDto} searchAssetDto * @param {SearchAssetDto} searchAssetDto
@ -6331,6 +6411,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/**
*
* @param {AssetJobsDto} assetJobsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async runAssetJobs(assetJobsDto: AssetJobsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.runAssetJobs(assetJobsDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* *
* @param {SearchAssetDto} searchAssetDto * @param {SearchAssetDto} searchAssetDto
@ -6584,6 +6674,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFileUploadResponseDto> { importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFileUploadResponseDto> {
return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath)); return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath));
}, },
/**
*
* @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
runAssetJobs(requestParameters: AssetApiRunAssetJobsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.runAssetJobs(requestParameters.assetJobsDto, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {AssetApiSearchAssetRequest} requestParameters Request parameters. * @param {AssetApiSearchAssetRequest} requestParameters Request parameters.
@ -7066,6 +7165,20 @@ export interface AssetApiImportFileRequest {
readonly importAssetDto: ImportAssetDto readonly importAssetDto: ImportAssetDto
} }
/**
* Request parameters for runAssetJobs operation in AssetApi.
* @export
* @interface AssetApiRunAssetJobsRequest
*/
export interface AssetApiRunAssetJobsRequest {
/**
*
* @type {AssetJobsDto}
* @memberof AssetApiRunAssetJobs
*/
readonly assetJobsDto: AssetJobsDto
}
/** /**
* Request parameters for searchAsset operation in AssetApi. * Request parameters for searchAsset operation in AssetApi.
* @export * @export
@ -7472,6 +7585,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath));
} }
/**
*
* @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public runAssetJobs(requestParameters: AssetApiRunAssetJobsRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).runAssetJobs(requestParameters.assetJobsDto, options).then((request) => request(this.axios, this.basePath));
}
/** /**
* *
* @param {AssetApiSearchAssetRequest} requestParameters Request parameters. * @param {AssetApiSearchAssetRequest} requestParameters Request parameters.

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { clickOutside } from '$lib/utils/click-outside'; import { clickOutside } from '$lib/utils/click-outside';
import type { AssetResponseDto } from '@api'; import { AssetJobName, AssetResponseDto, AssetTypeEnum, api } from '@api';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte'; import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte'; import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
@ -29,7 +29,22 @@
const isOwner = asset.ownerId === $page.data.user?.id; const isOwner = asset.ownerId === $page.data.user?.id;
const dispatch = createEventDispatcher(); type MenuItemEvent = 'addToAlbum' | 'addToSharedAlbum' | 'asProfileImage' | 'runJob';
const dispatch = createEventDispatcher<{
goBack: void;
stopMotionPhoto: void;
playMotionPhoto: void;
download: void;
showDetail: void;
favorite: void;
delete: void;
toggleArchive: void;
addToAlbum: void;
addToSharedAlbum: void;
asProfileImage: void;
runJob: AssetJobName;
}>();
let contextMenuPosition = { x: 0, y: 0 }; let contextMenuPosition = { x: 0, y: 0 };
let isShowAssetOptions = false; let isShowAssetOptions = false;
@ -39,7 +54,12 @@
isShowAssetOptions = !isShowAssetOptions; isShowAssetOptions = !isShowAssetOptions;
}; };
const onMenuClick = (eventName: string) => { const onJobClick = (name: AssetJobName) => {
isShowAssetOptions = false;
dispatch('runJob', name);
};
const onMenuClick = (eventName: MenuItemEvent) => {
isShowAssetOptions = false; isShowAssetOptions = false;
dispatch(eventName); dispatch(eventName);
}; };
@ -114,22 +134,35 @@
{#if isOwner} {#if isOwner}
<CircleIconButton isOpacity={true} logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" /> <CircleIconButton isOpacity={true} logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
<div use:clickOutside on:outclick={() => (isShowAssetOptions = false)}> <div use:clickOutside on:outclick={() => (isShowAssetOptions = false)}>
<CircleIconButton isOpacity={true} logo={DotsVertical} on:click={showOptionsMenu} title="More"> <CircleIconButton isOpacity={true} logo={DotsVertical} on:click={showOptionsMenu} title="More" />
{#if isShowAssetOptions} {#if isShowAssetOptions}
<ContextMenu {...contextMenuPosition} direction="left"> <ContextMenu {...contextMenuPosition} direction="left">
<MenuOption on:click={() => onMenuClick('addToAlbum')} text="Add to Album" /> <MenuOption on:click={() => onMenuClick('addToAlbum')} text="Add to Album" />
<MenuOption on:click={() => onMenuClick('addToSharedAlbum')} text="Add to Shared Album" /> <MenuOption on:click={() => onMenuClick('addToSharedAlbum')} text="Add to Shared Album" />
{#if isOwner} {#if isOwner}
<MenuOption
on:click={() => dispatch('toggleArchive')}
text={asset.isArchived ? 'Unarchive' : 'Archive'}
/>
<MenuOption on:click={() => onMenuClick('asProfileImage')} text="As profile picture" />
<MenuOption
on:click={() => onJobClick(AssetJobName.RefreshMetadata)}
text={api.getAssetJobName(AssetJobName.RefreshMetadata)}
/>
<MenuOption
on:click={() => onJobClick(AssetJobName.RegenerateThumbnail)}
text={api.getAssetJobName(AssetJobName.RegenerateThumbnail)}
/>
{#if asset.type === AssetTypeEnum.Video}
<MenuOption <MenuOption
on:click={() => dispatch('toggleArchive')} on:click={() => onJobClick(AssetJobName.TranscodeVideo)}
text={asset.isArchived ? 'Unarchive' : 'Archive'} text={api.getAssetJobName(AssetJobName.TranscodeVideo)}
/> />
{/if} {/if}
<MenuOption on:click={() => onMenuClick('asProfileImage')} text="As profile picture" /> {/if}
</ContextMenu> </ContextMenu>
{/if} {/if}
</CircleIconButton>
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { AlbumResponseDto, api, AssetResponseDto, AssetTypeEnum, SharedLinkResponseDto } from '@api'; import { AlbumResponseDto, api, AssetJobName, AssetResponseDto, AssetTypeEnum, SharedLinkResponseDto } from '@api';
import { createEventDispatcher, onDestroy, onMount } from 'svelte'; import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte'; import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte'; import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
@ -245,6 +245,15 @@
return 'Asset'; return 'Asset';
} }
}; };
const handleRunJob = async (name: AssetJobName) => {
try {
await api.assetApi.runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
notificationController.show({ type: NotificationType.Info, message: api.getAssetJobMessage(name) });
} catch (error) {
handleError(error, `Unable to submit job`);
}
};
</script> </script>
<section <section
@ -270,6 +279,7 @@
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)} on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
on:toggleArchive={toggleArchive} on:toggleArchive={toggleArchive}
on:asProfileImage={() => (isShowProfileImageCrop = true)} on:asProfileImage={() => (isShowProfileImageCrop = true)}
on:runJob={({ detail: job }) => handleRunJob(job)}
/> />
</div> </div>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { AssetJobName, AssetTypeEnum, api } from '@api';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
export let jobs: AssetJobName[] = [
AssetJobName.RegenerateThumbnail,
AssetJobName.RefreshMetadata,
AssetJobName.TranscodeVideo,
];
const { getAssets, clearSelect } = getAssetControlContext();
$: isAllVideos = Array.from(getAssets()).every((asset) => asset.type === AssetTypeEnum.Video);
const handleRunJob = async (name: AssetJobName) => {
try {
const ids = Array.from(getAssets()).map(({ id }) => id);
await api.assetApi.runAssetJobs({ assetJobsDto: { assetIds: ids, name } });
notificationController.show({ message: api.getAssetJobMessage(name), type: NotificationType.Info });
clearSelect();
} catch (error) {
handleError(error, 'Unable to submit job');
}
};
</script>
{#each jobs as job}
{#if isAllVideos || job !== AssetJobName.TranscodeVideo}
<MenuOption text={api.getAssetJobName(job)} on:click={() => handleRunJob(job)} />
{/if}
{/each}

View file

@ -2,6 +2,7 @@
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
@ -52,6 +53,7 @@
<FavoriteAction menuItem removeFavorite={isAllFavorite} /> <FavoriteAction menuItem removeFavorite={isAllFavorite} />
<DownloadAction menuItem /> <DownloadAction menuItem />
<ArchiveAction menuItem onArchive={(ids) => assetStore.removeAssets(ids)} /> <ArchiveAction menuItem onArchive={(ids) => assetStore.removeAssets(ids)} />
<AssetJobActions />
</AssetSelectContextMenu> </AssetSelectContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
{/if} {/if}