1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 06:31:58 +00:00
This commit is contained in:
Jason Rasmussen 2024-04-16 21:08:36 -04:00
parent 14b1425e98
commit 3fd5a32a7d
No known key found for this signature in database
GPG key ID: 75AD31BF84C94773
22 changed files with 665 additions and 2 deletions

View file

@ -14,6 +14,7 @@ import { MemoryController } from 'src/controllers/memory.controller';
import { OAuthController } from 'src/controllers/oauth.controller';
import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller';
import { PluginController } from 'src/controllers/plugin.controller';
import { SearchController } from 'src/controllers/search.controller';
import { ServerInfoController } from 'src/controllers/server-info.controller';
import { SharedLinkController } from 'src/controllers/shared-link.controller';
@ -41,6 +42,7 @@ export const controllers = [
MemoryController,
OAuthController,
PartnerController,
PluginController,
SearchController,
ServerInfoController,
SharedLinkController,

View file

@ -0,0 +1,49 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { PluginImportDto, PluginResponseDto, PluginUpdateDto, SearchPluginDto } from 'src/dtos/plugin.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PluginService } from 'src/services/plugin.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Plugin')
@Controller('plugins')
@Authenticated()
export class PluginController {
constructor(private service: PluginService) {}
@Get()
searchPlugins(@Auth() auth: AuthDto, @Query() dto: SearchPluginDto): Promise<PluginResponseDto[]> {
return this.service.search(auth, dto);
}
@Post()
importPlugin(@Auth() auth: AuthDto, @Body() dto: PluginImportDto): Promise<PluginResponseDto> {
return this.service.import(auth, dto);
}
@Put(':id')
updatePlugin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: PluginUpdateDto,
): Promise<PluginResponseDto> {
return this.service.update(auth, id, dto);
}
@Post(':id/install')
installPlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PluginResponseDto> {
return this.service.install(auth, id);
}
@Post(':id/uninstall')
uninstallPlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PluginResponseDto> {
return this.service.uninstall(auth, id);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
deletePlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}

View file

@ -43,6 +43,13 @@ export enum Permission {
PERSON_CREATE = 'person.create',
PERSON_REASSIGN = 'person.reassign',
PLUGIN_READ = 'plugin.read',
PLUGIN_WRITE = 'plugin.write',
PLUGIN_DELETE = 'plugin.delete',
PLUGIN_ADMIN = 'plugin.admin',
PLUGIN_INSTALL = 'plugin.install',
PLUGIN_UNINSTALL = 'plugin.uninstall',
PARTNER_UPDATE = 'partner.update',
}

View file

@ -0,0 +1,58 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsString } from 'class-validator';
import { PluginEntity } from 'src/entities/plugin.entity';
import { Optional, ValidateBoolean } from 'src/validation';
export class SearchPluginDto {
@ValidateBoolean({ optional: true })
isEnabled?: boolean;
@ValidateBoolean({ optional: true })
isOfficial?: boolean;
@ValidateBoolean({ optional: true })
isInstalled?: boolean;
@IsString()
@Optional()
name?: string;
}
export class PluginImportDto {
url!: string;
install!: boolean;
isEnabled!: boolean;
isOfficial!: boolean;
}
export class PluginUpdateDto {
@IsBoolean()
isEnabled!: boolean;
}
export class PluginResponseDto {
id!: string;
createdAt!: Date;
updatedAt!: Date;
packageId!: string;
@ApiProperty({ type: 'integer' })
version!: number;
name!: string;
description!: string;
isEnabled!: boolean;
isInstalled!: boolean;
isTrusted!: boolean;
}
export const mapPlugin = (plugin: PluginEntity): PluginResponseDto => ({
id: plugin.id,
createdAt: plugin.createdAt,
updatedAt: plugin.updatedAt,
packageId: plugin.packageId,
version: plugin.version,
name: plugin.name,
description: plugin.description,
isEnabled: plugin.isEnabled,
isInstalled: plugin.isInstalled,
isTrusted: plugin.isTrusted,
});

View file

@ -13,6 +13,7 @@ import { MemoryEntity } from 'src/entities/memory.entity';
import { MoveEntity } from 'src/entities/move.entity';
import { PartnerEntity } from 'src/entities/partner.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { PluginEntity } from 'src/entities/plugin.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
@ -37,6 +38,7 @@ export const entities = [
MoveEntity,
PartnerEntity,
PersonEntity,
PluginEntity,
SharedLinkEntity,
SmartInfoEntity,
SmartSearchEntity,

View file

@ -0,0 +1,40 @@
import { Column, CreateDateColumn, DeleteDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('plugins')
export class PluginEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@DeleteDateColumn({ type: 'timestamptz', nullable: true })
deletedAt!: Date | null;
@Column({ unique: true })
packageId!: string;
@Column()
version!: number;
@Column()
name!: string;
@Column()
description!: string;
@Column({ type: 'boolean', default: true })
isEnabled!: boolean;
@Column({ type: 'boolean', default: false })
isInstalled!: boolean;
@Column({ type: 'boolean', default: false })
isTrusted!: boolean;
@Column({ nullable: true })
installPath!: string | null;
}

View file

@ -89,6 +89,9 @@ export enum JobName {
SIDECAR_DISCOVERY = 'sidecar-discovery',
SIDECAR_SYNC = 'sidecar-sync',
SIDECAR_WRITE = 'sidecar-write',
// workflows
WORKFLOW_TRIGGER = 'workflow-trigger',
}
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
@ -135,6 +138,17 @@ export interface IDeferrableJob extends IEntityJob {
deferred?: boolean;
}
export enum WorkflowTriggerType {
ASSET_UPLOAD = 'asset.upload',
ASSET_UPDATE = 'asset.update',
ASSET_TRASH = 'asset.trash',
ASSET_DELETE = 'asset.delete',
}
export type IWorkflowTriggerJob =
| { type: WorkflowTriggerType.ASSET_UPLOAD; data: { assetId: string } }
| { type: WorkflowTriggerType.ASSET_UPDATE; data: { asset2Id: string } };
export interface JobCounts {
active: number;
completed: number;
@ -216,7 +230,8 @@ export type JobItem =
| { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob }
| { name: JobName.LIBRARY_DELETE; data: IEntityJob }
| { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob }
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob };
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }
| { name: JobName.WORKFLOW_TRIGGER; data: IWorkflowTriggerJob };
export enum JobStatus {
SUCCESS = 'success',

View file

@ -0,0 +1,116 @@
import { PluginEntity } from 'src/entities/plugin.entity';
export const IPluginRepository = 'IPluginRepository';
export interface PluginSearchOptions {
id?: string;
namespace?: string;
version?: number;
name?: string;
isEnabled?: boolean;
isInstalled?: boolean;
isOfficial?: boolean;
}
export interface IPluginRepository {
search(options: PluginSearchOptions): Promise<PluginEntity[]>;
create(dto: Partial<PluginEntity>): Promise<PluginEntity>;
get(id: string): Promise<PluginEntity | null>;
update(dto: Partial<PluginEntity>): Promise<PluginEntity>;
delete(id: string): Promise<void>;
download(url: string, downloadPath: string): Promise<void>;
load(pluginPath: string): Promise<PluginLike>;
}
export type PluginFactory = {
register: () => MaybePromise<Plugin>;
};
export type PluginLike = Plugin | PluginFactory | { default: Plugin } | { plugin: Plugin };
export interface Plugin<T extends PluginConfig | undefined = undefined> {
version: 1;
id: string;
name: string;
description: string;
actions: PluginAction<T>[];
}
export type PluginAction<T extends PluginConfig | undefined = undefined> = {
id: string;
name: string;
description: string;
events?: EventType[];
config?: T;
} & (
| { type: ActionType.ASSET; onAction: OnAction<T, AssetDto> }
| { type: ActionType.ALBUM; onAction: OnAction<T, AlbumDto> }
| { type: ActionType.ALBUM_ASSET; onAction: OnAction<T, { asset: AssetDto; album: AlbumDto }> }
);
export type OnAction<T extends PluginConfig | undefined, D = PluginActionData> = T extends undefined
? (ctx: PluginContext, data: D) => MaybePromise<void>
: (ctx: PluginContext, data: D, config: InferConfig<T>) => MaybePromise<void>;
export interface PluginContext {
updateAsset: (asset: { id: string; isArchived: boolean }) => Promise<void>;
}
export type PluginActionData = { data: { asset?: AssetDto; album?: AlbumDto } } & (
| { type: EventType.ASSET_UPLOAD; data: { asset: AssetDto } }
| { type: EventType.ASSET_UPDATE; data: { asset: AssetDto } }
| { type: EventType.ASSET_TRASH; data: { asset: AssetDto } }
| { type: EventType.ASSET_DELETE; data: { asset: AssetDto } }
| { type: EventType.ALBUM_CREATE; data: { album: AlbumDto } }
| { type: EventType.ALBUM_UPDATE; data: { album: AlbumDto } }
);
export type PluginConfig = Record<string, ConfigItem>;
export type ConfigItem = {
name: string;
description: string;
required?: boolean;
} & { [K in Types]: { type: K; default?: InferType<K> } }[Types];
export type InferType<T extends Types> = T extends 'string'
? string
: T extends 'date'
? Date
: T extends 'number'
? number
: T extends 'boolean'
? boolean
: never;
type Types = 'string' | 'boolean' | 'number' | 'date';
type MaybePromise<T = void> = Promise<T> | T;
type IfRequired<T extends ConfigItem, Type> = T['required'] extends true ? Type : Type | undefined;
type InferConfig<T> = T extends PluginConfig
? {
[K in keyof T]: IfRequired<T[K], InferType<T[K]['type']>>;
}
: never;
export enum ActionType {
ASSET = 'asset',
ALBUM = 'album',
ALBUM_ASSET = 'album-asset',
}
export enum EventType {
ASSET_UPLOAD = 'asset.upload',
ASSET_UPDATE = 'asset.update',
ASSET_TRASH = 'asset.trash',
ASSET_DELETE = 'asset.delete',
ASSET_ARCHIVE = 'asset.archvie',
ASSET_UNARCHIVE = 'asset.unarchive',
ALBUM_CREATE = 'album.create',
ALBUM_UPDATE = 'album.update',
ALBUM_DELETE = 'album.delete',
}
export type AssetDto = { id: string; type: 'asset' };
export type AlbumDto = { id: string; type: 'album' };

View file

@ -0,0 +1,55 @@
import { ActionType, AssetDto, Plugin, PluginContext } from 'src/interfaces/plugin.interface';
const onAsset = async (ctx: PluginContext, asset: AssetDto) => {
await ctx.updateAsset({ id: asset.id, isArchived: true });
};
export const plugin: Plugin = {
version: 1,
id: 'immich-plugin-asset',
name: 'Asset Plugin',
description: 'Immich asset plugin',
actions: [
{
id: 'asset.favorite',
name: '',
type: ActionType.ASSET,
description: '',
onAction: async (ctx, asset) => {
await ctx.updateAsset({ id: asset.id, isArchived: false });
},
},
{
id: 'asset.unfavorite',
name: '',
type: ActionType.ASSET,
description: '',
onAction: () => {
console.log('Unfavorite');
},
},
{
id: 'asset.action',
name: '',
type: ActionType.ASSET,
description: '',
onAction: (ctx, asset) => onAsset(ctx, asset),
},
{
id: 'album-asset.action',
name: '',
type: ActionType.ALBUM_ASSET,
description: '',
onAction: (ctx, { asset }) => onAsset(ctx, asset),
},
{
id: 'asset.unarchive',
name: '',
type: ActionType.ASSET,
description: '',
onAction: () => {
console.log('Unarchive');
},
},
],
};

View file

@ -20,6 +20,7 @@ import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IPluginRepository } from 'src/interfaces/plugin.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
@ -51,6 +52,7 @@ import { MetricRepository } from 'src/repositories/metric.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
@ -83,6 +85,7 @@ export const repositories = [
{ provide: IMoveRepository, useClass: MoveRepository },
{ provide: IPartnerRepository, useClass: PartnerRepository },
{ provide: IPersonRepository, useClass: PersonRepository },
{ provide: IPluginRepository, useClass: PluginRepository },
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: ISearchRepository, useClass: SearchRepository },

View file

@ -77,6 +77,9 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,
// workflows
[JobName.WORKFLOW_TRIGGER]: QueueName.BACKGROUND_TASK,
};
@Instrumentation()

View file

@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { writeFile } from 'node:fs/promises';
import { PluginEntity } from 'src/entities/plugin.entity';
import { IPluginRepository, PluginLike, PluginSearchOptions } from 'src/interfaces/plugin.interface';
import { ImmichLogger } from 'src/utils/logger';
import { Repository } from 'typeorm';
@Injectable()
export class PluginRepository implements IPluginRepository {
private logger = new ImmichLogger(PluginRepository.name);
constructor(@InjectRepository(PluginEntity) private repository: Repository<PluginEntity>) {}
search(options: PluginSearchOptions): Promise<PluginEntity[]> {
return this.repository.find({
where: {
id: options.id,
packageId: options.namespace,
version: options.version,
name: options.name,
isEnabled: options.isEnabled,
isInstalled: options.isInstalled,
isTrusted: options.isOfficial,
},
});
}
create(dto: Partial<PluginEntity>): Promise<PluginEntity> {
return this.repository.save(dto);
}
get(id: string): Promise<PluginEntity | null> {
return this.repository.findOne({ where: { id } });
}
update(dto: Partial<PluginEntity>): Promise<PluginEntity> {
return this.repository.save(dto);
}
async delete(id: string): Promise<void> {
await this.repository.delete({ id });
}
async download(url: string, downloadPath: string): Promise<void> {
try {
const { json } = await fetch(url);
await writeFile(downloadPath, await json());
} catch (error) {
this.logger.error(`Error downloading the plugin from ${url}. ${error}`);
}
}
load(pluginPath: string): Promise<PluginLike> {
return import(pluginPath);
}
}

View file

@ -16,6 +16,7 @@ import { MetadataService } from 'src/services/metadata.service';
import { MicroservicesService } from 'src/services/microservices.service';
import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service';
import { PluginService } from 'src/services/plugin.service';
import { SearchService } from 'src/services/search.service';
import { ServerInfoService } from 'src/services/server-info.service';
import { SharedLinkService } from 'src/services/shared-link.service';
@ -28,6 +29,7 @@ import { TagService } from 'src/services/tag.service';
import { TimelineService } from 'src/services/timeline.service';
import { TrashService } from 'src/services/trash.service';
import { UserService } from 'src/services/user.service';
import { WorkflowService } from 'src/services/workflow.service';
export const services = [
ApiService,
@ -48,6 +50,7 @@ export const services = [
MetadataService,
PartnerService,
PersonService,
PluginService,
SearchService,
ServerInfoService,
SharedLinkService,
@ -60,4 +63,5 @@ export const services = [
TimelineService,
TrashService,
UserService,
WorkflowService,
];

View file

@ -16,6 +16,7 @@ import {
JobStatus,
QueueCleanType,
QueueName,
WorkflowTriggerType,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface';
@ -294,6 +295,13 @@ export class JobService {
if (asset && asset.isVisible) {
this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
}
if (asset) {
await this.jobRepository.queue({
name: JobName.WORKFLOW_TRIGGER,
data: { type: WorkflowTriggerType.ASSET_UPLOAD, data: { assetId: asset.id } },
});
}
break;
}

View file

@ -13,6 +13,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service';
import { StorageService } from 'src/services/storage.service';
import { SystemConfigService } from 'src/services/system-config.service';
import { UserService } from 'src/services/user.service';
import { WorkflowService } from 'src/services/workflow.service';
import { otelSDK } from 'src/utils/instrumentation';
@Injectable()
@ -31,6 +32,7 @@ export class MicroservicesService {
private storageService: StorageService,
private userService: UserService,
private databaseService: DatabaseService,
private workflowService: WorkflowService,
) {}
async init() {
@ -77,6 +79,7 @@ export class MicroservicesService {
[JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data),
[JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data),
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
[JobName.WORKFLOW_TRIGGER]: (data) => this.workflowService.handleTrigger(data),
});
await this.metadataService.init();

View file

@ -0,0 +1,108 @@
import { BadRequestException, Inject } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { PluginImportDto, PluginUpdateDto, SearchPluginDto, mapPlugin } from 'src/dtos/plugin.dto';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IPluginRepository, Plugin, PluginFactory } from 'src/interfaces/plugin.interface';
export class PluginService {
private access: AccessCore;
constructor(
@Inject(IPluginRepository) private pluginRepository: IPluginRepository,
@Inject(IAccessRepository) accessRepository: IAccessRepository,
) {
this.access = AccessCore.create(accessRepository);
}
search(auth: AuthDto, dto: SearchPluginDto) {
// await this.access.requirePermission(authUser, Permission.PLUGIN_READ);
return this.pluginRepository.search(dto);
}
async import(auth: AuthDto, dto: PluginImportDto) {
// await this.access.requirePermission(authUser, Permission.PLUGIN_ADMIN);
if (dto.url.startsWith('http')) {
//
}
const pluginPath = '/path/to/plugin';
await this.pluginRepository.download(dto.url, pluginPath);
const { version, id, name, description } = await this.load(pluginPath);
const response = await this.pluginRepository.create({
version,
packageId: id,
name,
description,
isInstalled: false,
isEnabled: dto.isEnabled,
isTrusted: false,
installPath: pluginPath,
});
return mapPlugin(response);
}
async install(auth: AuthDto, id: string) {
await this.access.requirePermission(auth, Permission.PLUGIN_INSTALL, id);
// TODO
return this.pluginRepository.update({ id, isInstalled: true });
}
async uninstall(auth: AuthDto, id: string) {
await this.access.requirePermission(auth, Permission.PLUGIN_UNINSTALL, id);
// TODO
return this.pluginRepository.update({ id, isInstalled: false, installPath: null });
}
async update(auth: AuthDto, id: string, dto: PluginUpdateDto) {
await this.access.requirePermission(auth, Permission.PLUGIN_WRITE, id);
return this.pluginRepository.update({
id,
isEnabled: dto.isEnabled,
});
}
async delete(auth: AuthDto, id: string) {
await this.access.requirePermission(auth, Permission.PLUGIN_WRITE, id);
await this.findOrFail(id);
await this.pluginRepository.delete(id);
}
private async findOrFail(id: string) {
const plugin = await this.pluginRepository.get(id);
if (!plugin) {
throw new BadRequestException('Plugin not found');
}
return plugin;
}
// TODO security implications
private async load(pluginPath: string): Promise<Plugin> {
const pluginLike = await this.pluginRepository.load(pluginPath);
let plugin: Plugin | undefined;
const pluginModule = pluginLike as { default: Plugin };
if (pluginModule.default) {
plugin = pluginModule.default;
}
const pluginExport = pluginLike as { plugin: Plugin };
if (pluginExport.plugin) {
plugin = pluginExport.plugin;
}
const pluginFactory = pluginLike as PluginFactory;
if (pluginFactory) {
plugin = await pluginFactory.register();
}
// TODO use class-validator
const isPlugin = plugin && !!plugin.version && Array.isArray(plugin.actions);
if (isPlugin) {
return plugin as Plugin;
}
throw new Error('Unable to load plugin');
}
}

View file

@ -0,0 +1,50 @@
import { Inject, Injectable } from '@nestjs/common';
import { IWorkflowTriggerJob, JobStatus, WorkflowTriggerType } from 'src/interfaces/job.interface';
import { IPluginRepository, PluginLike } from 'src/interfaces/plugin.interface';
@Injectable()
export class WorkflowService {
private plugins?: PluginLike[];
constructor(@Inject(IPluginRepository) private pluginRepository: IPluginRepository) {}
async init(): Promise<void> {
const activePlugins = await this.pluginRepository.search({ isEnabled: true });
this.plugins = await Promise.all(activePlugins.map(({ installPath }) => this.pluginRepository.load(installPath)));
}
// async register() {
// const plugins = ['/src/abc'];
// for (const pluginModule of plugins) {
// // eslint-disable-next-line @typescript-eslint/no-var-requires
// try {
// const plugin: Plugin = ;
// const actions = await plugin.register();
// for (const action of actions) {
// this.actions[action.id] = action;
// }
// } catch (error) {
// console.error(`Unable to load module: ${pluginModule}`, error);
// continue;
// }
// }
// }
async handleTrigger(data: IWorkflowTriggerJob): Promise<JobStatus> {
if (data.type === WorkflowTriggerType.ASSET_UPLOAD) {
// const stuff = data.data;
return JobStatus.SUCCESS;
}
await Promise.resolve();
// const workflows = this.getWorkflows();
// const plugins = await this.getPlugins();
// for (const plugin of plugins) {
// if (workflow.trigger === data.type) {
// }
// }
return JobStatus.SKIPPED;
}
}

View file

@ -0,0 +1,12 @@
import { AssetDto, EventType, OnAction, PluginConfig } from 'src/interfaces/plugin.interface';
export const createPluginAction = <T extends PluginConfig | undefined = undefined>(options: {
id: string;
name: string;
description: string;
events?: EventType[];
config?: T;
}) => ({
addHandler: (onAction: OnAction<T>) => ({ ...options, onAction }),
onAsset: (onAction: OnAction<T, AssetDto>) => ({ ...options, onAction }),
});

View file

@ -3,7 +3,7 @@
import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
import StatusBox from '$lib/components/shared-components/status-box.svelte';
import { AppRoute } from '$lib/constants';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiTools } from '@mdi/js';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiPuzzle, mdiServer, mdiSync, mdiTools } from '@mdi/js';
</script>
<SideBarSection>
@ -14,6 +14,7 @@
<SideBarLink title="External Libraries" routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
<SideBarLink title="Server Stats" routeId={AppRoute.ADMIN_STATS} icon={mdiServer} />
<SideBarLink title="Repair" routeId={AppRoute.ADMIN_REPAIR} icon={mdiTools} preloadData={false} />
<SideBarLink title="Plugins" routeId={AppRoute.ADMIN_PLUGINS} icon={mdiPuzzle} preloadData={false} />
</nav>
<div class="mb-6 mt-auto">

View file

@ -16,6 +16,7 @@ export enum AppRoute {
ADMIN_STATS = '/admin/server-status',
ADMIN_JOBS = '/admin/jobs-status',
ADMIN_REPAIR = '/admin/repair',
ADMIN_PLUGINS = '/admin/plugins',
ALBUMS = '/albums',
LIBRARIES = '/libraries',

View file

@ -0,0 +1,54 @@
<script lang="ts">
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { mdiCheckDecagram, mdiWrench } from '@mdi/js';
import type { PageData } from './$types';
import { range } from 'lodash-es';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let data: PageData;
const plugins = range(0, 8).map((index) => ({
name: `Plugin-${index}`,
description: `Plugin ${index} is awesome because it can do x and even y!`,
isEnabled: Math.random() < 0.5,
isInstalled: Math.random() < 0.5,
isOfficial: Math.random() < 0.5,
version: 1,
}));
</script>
<UserPageLayout title={data.meta.title} admin>
<svelte:fragment slot="sidebar" />
<div class="flex justify-end gap-2" slot="buttons">
<LinkButton on:click={() => console.log('clicked')}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiWrench} size="18" />
Test
</div>
</LinkButton>
</div>
<section id="setting-content" class="flex place-content-center sm:mx-4">
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
{#each plugins as plugin}
<section
class="flex flex-col gap-4 justify-between dark:bg-immich-dark-gray bg-immich-gray dark:border-0 border-gray-200 border border-solid rounded-2xl p-4"
>
<div class="flex flex-col gap-2">
<h1 class="m-0 items-start flex gap-2">
{plugin.name}
{#if plugin.isOfficial}
<Icon path={mdiCheckDecagram} size="18" color="green" />
{/if}
<div class="place-self-end justify-self-end justify-end self-end">Version {plugin.version}</div>
</h1>
<p class="m-0 text-sm text-gray-600 dark:text-gray-300">{plugin.description}</p>
</div>
<div class="flex">Is {plugin.isInstalled ? '' : 'not '}installed</div>
<SettingSwitch checked={plugin.isEnabled} id={plugin.name} title="Enabled" />
</section>
{/each}
</div>
</section>
</UserPageLayout>

View file

@ -0,0 +1,16 @@
import { authenticate } from '$lib/utils/auth';
import { getAuditFiles } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async () => {
await authenticate({ admin: true });
const { orphans, extras } = await getAuditFiles();
return {
orphans,
extras,
meta: {
title: 'Plugins',
},
};
}) satisfies PageLoad;