mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00: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:
@@ -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' });
|
||||
|
||||
124
web/src/api/open-api/api.ts
generated
124
web/src/api/open-api/api.ts
generated
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user