1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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
* @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
@ -5784,6 +5820,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
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
@ -6331,6 +6411,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options);
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
@ -6584,6 +6674,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFileUploadResponseDto> {
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.
@ -7066,6 +7165,20 @@ export interface AssetApiImportFileRequest {
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.
* @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));
}
/**
*
* @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.

View File

@ -23,6 +23,8 @@ doc/AssetBulkUploadCheckResult.md
doc/AssetFileUploadResponseDto.md
doc/AssetIdsDto.md
doc/AssetIdsResponseDto.md
doc/AssetJobName.md
doc/AssetJobsDto.md
doc/AssetResponseDto.md
doc/AssetStatsResponseDto.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_ids_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_stats_response_dto.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_ids_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_stats_response_dto_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": {
"get": {
"operationId": "getMapMarkers",
@ -5042,6 +5077,33 @@
],
"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": {
"properties": {
"checksum": {

View File

@ -7,15 +7,17 @@ import {
newAccessRepositoryMock,
newAssetRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock,
newStorageRepositoryMock,
} from '@test';
import { when } from 'jest-when';
import { Readable } from 'stream';
import { ICryptoRepository } from '../crypto';
import { IJobRepository, JobName } from '../index';
import { IStorageRepository } from '../storage';
import { AssetStats, IAssetRepository } from './asset.repository';
import { AssetService, UploadFieldName } from './asset.service';
import { AssetStatsResponseDto, DownloadResponseDto } from './dto';
import { AssetJobName, AssetStatsResponseDto, DownloadResponseDto } from './dto';
import { mapAsset } from './response-dto';
const downloadResponse: DownloadResponseDto = {
@ -145,6 +147,7 @@ describe(AssetService.name, () => {
let accessMock: IAccessRepositoryMock;
let assetMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
it('should work', () => {
@ -155,8 +158,9 @@ describe(AssetService.name, () => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new AssetService(accessMock, assetMock, cryptoMock, storageMock);
sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, storageMock);
});
describe('canUpload', () => {
@ -532,4 +536,24 @@ describe(AssetService.name, () => {
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 { mimeTypes } from '../domain.constant';
import { HumanReadableSize, usePagination } from '../domain.util';
import { IJobRepository, JobName } from '../job';
import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { IAssetRepository } from './asset.repository';
import {
AssetBulkUpdateDto,
AssetIdsDto,
AssetJobName,
AssetJobsDto,
DownloadArchiveInfo,
DownloadInfoDto,
DownloadResponseDto,
@ -54,6 +57,7 @@ export class AssetService {
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.access = new AccessCore(accessRepository);
@ -275,4 +279,24 @@ export class AssetService {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
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';
export class AssetIdsDto {
@ValidateUUID({ each: true })
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 {
AssetBulkUpdateDto,
AssetIdsDto,
AssetJobsDto,
AssetResponseDto,
AssetService,
AssetStatsDto,
@ -78,6 +79,12 @@ export class AssetController {
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()
@HttpCode(HttpStatus.NO_CONTENT)
updateAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {

View File

@ -3,6 +3,7 @@ import {
APIKeyApi,
AssetApi,
AssetApiFp,
AssetJobName,
AuthenticationApi,
Configuration,
ConfigurationParameters,
@ -120,6 +121,26 @@ export class ImmichApi {
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' });

View File

@ -525,6 +525,42 @@ export const 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
@ -5784,6 +5820,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
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
@ -6331,6 +6411,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options);
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
@ -6584,6 +6674,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFileUploadResponseDto> {
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.
@ -7066,6 +7165,20 @@ export interface AssetApiImportFileRequest {
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.
* @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));
}
/**
*
* @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.

View File

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

View File

@ -1,6 +1,6 @@
<script lang="ts">
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 ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
@ -245,6 +245,15 @@
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>
<section
@ -270,6 +279,7 @@
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
on:toggleArchive={toggleArchive}
on:asProfileImage={() => (isShowProfileImageCrop = true)}
on:runJob={({ detail: job }) => handleRunJob(job)}
/>
</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 AddToAlbum from '$lib/components/photos-page/actions/add-to-album.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 DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
@ -52,6 +53,7 @@
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
<DownloadAction menuItem />
<ArchiveAction menuItem onArchive={(ids) => assetStore.removeAssets(ids)} />
<AssetJobActions />
</AssetSelectContextMenu>
</AssetSelectControlBar>
{/if}