mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 06:31:58 +00:00
WIP
This commit is contained in:
parent
14b1425e98
commit
3fd5a32a7d
22 changed files with 665 additions and 2 deletions
|
@ -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,
|
||||
|
|
49
server/src/controllers/plugin.controller.ts
Normal file
49
server/src/controllers/plugin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
|
58
server/src/dtos/plugin.dto.ts
Normal file
58
server/src/dtos/plugin.dto.ts
Normal 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,
|
||||
});
|
|
@ -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,
|
||||
|
|
40
server/src/entities/plugin.entity.ts
Normal file
40
server/src/entities/plugin.entity.ts
Normal 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;
|
||||
}
|
|
@ -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',
|
||||
|
|
116
server/src/interfaces/plugin.interface.ts
Normal file
116
server/src/interfaces/plugin.interface.ts
Normal 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' };
|
55
server/src/plugins/asset.plugin.ts
Normal file
55
server/src/plugins/asset.plugin.ts
Normal 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');
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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 },
|
||||
|
|
|
@ -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()
|
||||
|
|
56
server/src/repositories/plugin.repository.ts
Normal file
56
server/src/repositories/plugin.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
108
server/src/services/plugin.service.ts
Normal file
108
server/src/services/plugin.service.ts
Normal 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');
|
||||
}
|
||||
}
|
50
server/src/services/workflow.service.ts
Normal file
50
server/src/services/workflow.service.ts
Normal 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;
|
||||
}
|
||||
}
|
12
server/src/utils/plugin.ts
Normal file
12
server/src/utils/plugin.ts
Normal 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 }),
|
||||
});
|
|
@ -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">
|
||||
|
|
|
@ -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',
|
||||
|
|
54
web/src/routes/admin/plugins/+page.svelte
Normal file
54
web/src/routes/admin/plugins/+page.svelte
Normal 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>
|
16
web/src/routes/admin/plugins/+page.ts
Normal file
16
web/src/routes/admin/plugins/+page.ts
Normal 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;
|
Loading…
Reference in a new issue