Compare commits

...

1 Commits

Author SHA1 Message Date
Jason Rasmussen
a6b0f0c76a feat: plugins 2025-10-21 13:29:32 -04:00
23 changed files with 877 additions and 2 deletions

View File

@@ -1604,6 +1604,7 @@
"read_changelog": "Read Changelog",
"readonly_mode_disabled": "Read-only mode disabled",
"readonly_mode_enabled": "Read-only mode enabled",
"plugins": "Plugins",
"ready_for_upload": "Ready for upload",
"reassign": "Reassign",
"reassigned_assets_to_existing_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}",

View File

@@ -5717,6 +5717,154 @@
"description": "This endpoint requires the `person.read` permission."
}
},
"/plugins": {
"get": {
"operationId": "searchPlugins",
"parameters": [
{
"name": "isEnabled",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isInstalled",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isTrusted",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "name",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PluginResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Plugin"
],
"x-immich-admin-only": true,
"x-immich-permission": "plugin.read",
"description": "This endpoint is an admin-only route, and requires the `plugin.read` permission."
}
},
"/plugins/{id}": {
"delete": {
"operationId": "deletePlugin",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"tags": [
"Plugin"
]
},
"put": {
"operationId": "updatePlugin",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginUpdateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Plugin"
],
"x-immich-admin-only": true,
"x-immich-permission": "plugin.update",
"description": "This endpoint is an admin-only route, and requires the `plugin.update` permission."
}
},
"/search/cities": {
"get": {
"operationId": "getAssetsByCity",
@@ -13211,6 +13359,9 @@
"person.statistics",
"person.merge",
"person.reassign",
"plugin.read",
"plugin.update",
"plugin.delete",
"pinCode.create",
"pinCode.update",
"pinCode.delete",
@@ -13498,6 +13649,66 @@
],
"type": "object"
},
"PluginResponseDto": {
"properties": {
"createdAt": {
"format": "date-time",
"type": "string"
},
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"isEnabled": {
"type": "boolean"
},
"isInstalled": {
"type": "boolean"
},
"isTrusted": {
"type": "boolean"
},
"name": {
"type": "string"
},
"packageId": {
"type": "string"
},
"updatedAt": {
"format": "date-time",
"type": "string"
},
"version": {
"type": "integer"
}
},
"required": [
"createdAt",
"description",
"id",
"isEnabled",
"isInstalled",
"isTrusted",
"name",
"packageId",
"updatedAt",
"version"
],
"type": "object"
},
"PluginUpdateDto": {
"properties": {
"isEnabled": {
"type": "boolean"
}
},
"required": [
"isEnabled"
],
"type": "object"
},
"PurchaseResponse": {
"properties": {
"hideBuyButtonUntil": {

View File

@@ -18,6 +18,7 @@ import { NotificationController } from 'src/controllers/notification.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 { ServerController } from 'src/controllers/server.controller';
import { SessionController } from 'src/controllers/session.controller';
@@ -54,6 +55,7 @@ export const controllers = [
OAuthController,
PartnerController,
PersonController,
PluginController,
SearchController,
ServerController,
SessionController,

View File

@@ -0,0 +1,36 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { PluginResponseDto, PluginSearchDto, PluginUpdateDto } from 'src/dtos/plugin.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PluginService } from 'src/services/plugin.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Plugin')
@Controller('plugins')
export class PluginController {
constructor(private service: PluginService) {}
@Get()
@Authenticated({ admin: true, permission: Permission.PluginRead })
searchPlugins(@Auth() auth: AuthDto, @Query() dto: PluginSearchDto): Promise<PluginResponseDto[]> {
return this.service.search(auth, dto);
}
@Put(':id')
@Authenticated({ admin: true, permission: Permission.PluginUpdate })
updatePlugin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: PluginUpdateDto,
): Promise<PluginResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
deletePlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}

View File

@@ -144,6 +144,19 @@ export type UserAdmin = User & {
metadata: UserMetadataItem[];
};
export type Plugin = {
id: string;
createdAt: Date;
updatedAt: Date;
packageId: string;
version: number;
name: string;
description: string;
isEnabled: boolean;
isInstalled: boolean;
isTrusted: boolean;
};
export type StorageAsset = {
id: string;
ownerId: string;

View File

@@ -0,0 +1,58 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsString } from 'class-validator';
import { Plugin } from 'src/database';
import { Optional, ValidateBoolean } from 'src/validation';
export class PluginSearchDto {
@ValidateBoolean({ optional: true })
isEnabled?: boolean;
@ValidateBoolean({ optional: true })
isTrusted?: boolean;
@ValidateBoolean({ optional: true })
isInstalled?: boolean;
@IsString()
@Optional()
name?: string;
}
export class PluginImportDto {
url!: string;
install!: boolean;
isEnabled!: boolean;
isTrusted!: 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: Plugin): 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

@@ -164,6 +164,10 @@ export enum Permission {
PersonMerge = 'person.merge',
PersonReassign = 'person.reassign',
PluginRead = 'plugin.read',
PluginUpdate = 'plugin.update',
PluginDelete = 'plugin.delete',
PinCodeCreate = 'pinCode.create',
PinCodeUpdate = 'pinCode.update',
PinCodeDelete = 'pinCode.delete',

View File

@@ -0,0 +1,91 @@
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.archive',
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,92 @@
import sdk from '../../open-api/typescript-sdk';
export type Plugin = {
id: string;
name: string;
description: string;
filters: Filter[];
// actions: Action[];
};
export enum EntityType {
Asset = 'asset',
Album = 'album',
}
type PluginItem = {
id: string;
name: string;
description?: string;
type: EntityType;
configuration?: Config[];
};
type FilterContext<C = Record<string, any>, D = any> = {
api: {
getAssetAlbums: (assetId: string) => Promise<any[]>;
};
sdk: typeof sdk;
config: C;
};
type AssetFilter = {
type: EntityType.Asset;
filter: (ctx: FilterContext, input: { asset: { id: string } }) => Promise<boolean>;
};
type AlbumFilter = {
type: EntityType.Album;
filter: (ctx: FilterContext, input: { album: { id: string; name: string } }) => Promise<boolean>;
};
export type Filter = PluginItem & (AssetFilter | AlbumFilter);
export type Config = {
key: string;
type: PluginConfigType;
required?: boolean;
};
export type PluginConfigType = 'string' | 'number' | 'boolean' | 'date' | 'albumId' | 'assetId';
const authenticate = (ctx: FilterContext) => {
const
sdk.init()
}
export const corePlugin: Plugin = {
id: 'immich',
name: 'Immich Core Plugin',
description: 'Core actions and filters for workflows',
filters: [
{
id: 'core.notInAnyAlbum',
name: 'Not in any album',
description: 'Filters assets that are not in any album',
type: EntityType.Asset,
async filter(ctx, { asset }) {
const albums = await ctx.sdk.getAllAlbums({ assetId: asset.id });
return albums.length === 0;
},
},
{
id: 'core.notInAlbum',
name: 'Not in an album',
description: 'Run on assets not in the specified album',
type: EntityType.Asset,
configuration: [
{
key: 'albumId',
type: 'string',
required: true,
},
],
async filter(ctx, { asset }) {
// missing api to check if an asset is in an album
const albums = await ctx.sdk.getAllAlbums({ assetId: asset.id });
return !!albums.find((album) => album.id === ctx.config.albumId);
},
},
],
};

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-plugins',
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

@@ -27,6 +27,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
@@ -44,7 +45,6 @@ import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { ViewRepository } from 'src/repositories/view-repository';
export const repositories = [
AccessRepository,
ActivityRepository,
@@ -76,6 +76,7 @@ export const repositories = [
PartnerRepository,
PersonRepository,
ProcessRepository,
PluginRepository,
SearchRepository,
SessionRepository,
ServerInfoRepository,

View File

@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { writeFile } from 'node:fs/promises';
import { PluginLike } from 'src/interfaces/plugin.interface';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { DB } from 'src/schema';
import { PluginTable } from 'src/schema/tables/plugin.table';
type PluginSearchOptions = {
id?: string;
namespace?: string;
version?: number;
name?: string;
isEnabled?: boolean;
isInstalled?: boolean;
isTrusted?: boolean;
};
@Injectable()
export class PluginRepository {
constructor(
@InjectKysely() private db: Kysely<DB>,
private logger: LoggingRepository,
) {
this.logger.setContext(PluginRepository.name);
}
search(options: PluginSearchOptions) {
return this.db
.selectFrom('plugin')
.select([
'id',
'packageId',
'version',
'name',
'description',
'isEnabled',
'isInstalled',
'isTrusted',
'requirePath',
'createdAt',
'updatedAt',
'deletedAt',
])
.$if(!!options.id, (qb) => qb.where('id', '=', options.id!))
.$if(!!options.version, (qb) => qb.where('version', '=', options.version!))
.$if(!!options.name, (qb) => qb.where('name', '=', options.name!))
.$if(!!options.isEnabled, (qb) => qb.where('isEnabled', '=', options.isEnabled!))
.$if(!!options.isInstalled, (qb) => qb.where('isInstalled', '=', options.isInstalled!))
.$if(!!options.isTrusted, (qb) => qb.where('isTrusted', '=', options.isTrusted!))
.execute();
}
create(dto: Insertable<PluginTable>) {
return this.db.insertInto('plugin').values(dto).returningAll().executeTakeFirstOrThrow();
}
get(id: string) {
return this.db.selectFrom('plugin').where('id', '=', id).executeTakeFirst();
}
update(dto: Updateable<PluginTable>) {
return this.db.updateTable('plugin').set(dto).returningAll().executeTakeFirstOrThrow();
}
async delete(id: string): Promise<void> {
await this.db.deleteFrom('plugin').where('id', '=', id).execute();
}
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

@@ -51,6 +51,7 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonAuditTable } from 'src/schema/tables/person-audit.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { PluginTable } from 'src/schema/tables/plugin.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
@@ -105,6 +106,7 @@ export class ImmichDatabase {
PartnerTable,
PersonTable,
PersonAuditTable,
PluginTable,
SessionTable,
SharedLinkAssetTable,
SharedLinkTable,
@@ -202,6 +204,8 @@ export interface DB {
person: PersonTable;
person_audit: PersonAuditTable;
plugin: PluginTable;
session: SessionTable;
session_sync_checkpoint: SessionSyncCheckpointTable;

View File

@@ -0,0 +1,61 @@
import { Insertable } from 'kysely';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
const plugin: Insertable<PluginTable> = {
version: 1,
id: '123',
name: 'Immich Core Plugin',
description: 'Core plugins for Immich',
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
packageId: 'immich-plugin-',
};
@Table('plugins')
export class PluginTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@DeleteDateColumn()
deletedAt!: Date | null;
@Column({ unique: true })
packageId!: string;
@Column()
version!: number;
@Column()
name!: string;
@Column()
description!: string;
@Column({ type: 'boolean', default: true })
isEnabled!: Generated<boolean>;
@Column({ type: 'boolean', default: false })
isInstalled!: Generated<boolean>;
@Column({ type: 'boolean', default: false })
isTrusted!: Generated<boolean>;
@Column({ nullable: true })
requirePath!: string | null;
}

View File

@@ -34,6 +34,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
@@ -138,6 +139,7 @@ export class BaseService {
protected oauthRepository: OAuthRepository,
protected partnerRepository: PartnerRepository,
protected personRepository: PersonRepository,
protected pluginRepository: PluginRepository,
protected processRepository: ProcessRepository,
protected searchRepository: SearchRepository,
protected serverInfoRepository: ServerInfoRepository,

View File

@@ -22,6 +22,7 @@ import { NotificationAdminService } from 'src/services/notification-admin.servic
import { NotificationService } from 'src/services/notification.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 { ServerService } from 'src/services/server.service';
import { SessionService } from 'src/services/session.service';
@@ -40,6 +41,7 @@ import { UserAdminService } from 'src/services/user-admin.service';
import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
import { ViewService } from 'src/services/view.service';
import { WorkflowService } from 'src/services/workflow.service';
export const services = [
ApiKeyService,
@@ -66,6 +68,7 @@ export const services = [
NotificationAdminService,
PartnerService,
PersonService,
PluginService,
SearchService,
ServerService,
SessionService,
@@ -84,4 +87,5 @@ export const services = [
UserService,
VersionService,
ViewService,
WorkflowService,
];

View File

@@ -0,0 +1,42 @@
import { Plugin } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import { mapPlugin, PluginSearchDto, PluginUpdateDto } from 'src/dtos/plugin.dto';
import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
const plugins: Plugin[] = [
{
id: '123',
name: 'Immich Core Plugin',
description: 'Core plugins for Immich',
version: 1,
isEnabled: true,
isInstalled: true,
isTrusted: true,
createdAt: new Date(),
updatedAt: new Date(),
packageId: 'immich-plugin-',
},
];
export class PluginService extends BaseService {
async search(auth: AuthDto, dto: PluginSearchDto) {
await this.requireAccess({ auth, permission: Permission.PluginRead, ids: [] });
// return this.pluginRepository.search(dto);
return plugins.map(mapPlugin);
}
async update(auth: AuthDto, id: string, dto: PluginUpdateDto) {
await this.requireAccess({ auth, permission: Permission.PluginUpdate, ids: [id] });
return this.pluginRepository.update({
id,
isEnabled: dto.isEnabled,
});
}
async delete(auth: AuthDto, id: string) {
await this.requireAccess({ auth, permission: Permission.PluginUpdate, ids: [id] });
await this.pluginRepository.delete(id);
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { PluginLike } from 'src/interfaces/plugin.interface';
import { BaseService } from 'src/services/base.service';
@Injectable()
export class WorkflowService extends BaseService {
private plugins?: PluginLike[];
async init(): Promise<void> {
const activePlugins = await this.pluginRepository.search({ isEnabled: true });
const installPaths = activePlugins.map((p) => p.requirePath).filter(Boolean) as string[];
this.plugins = await Promise.all(installPaths.map((path) => this.pluginRepository.load(path!)));
}
// 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;
// }
// }
// }
}

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

@@ -25,6 +25,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

@@ -2,7 +2,7 @@
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import { AppRoute } from '$lib/constants';
import { NavbarItem } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync } from '@mdi/js';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiConnection, mdiServer, mdiSync } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
@@ -10,6 +10,7 @@
<div class="flex flex-col pt-8 pe-4 gap-1">
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
<NavbarItem title={$t('jobs')} href={AppRoute.ADMIN_JOBS} icon={mdiSync} />
<NavbarItem title={$t('plugins')} href={AppRoute.ADMIN_PLUGINS} icon={mdiConnection} />
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import { Button, Icon, Switch } from '@immich/ui';
import { mdiCheckDecagram, mdiWrench } from '@mdi/js';
import { range } from 'lodash-es';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
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>
<AdminPageLayout title={data.meta.title}>
{#snippet buttons()}
<div class="flex gap-2 items-center justify-center">
<Button leadingIcon={mdiWrench} onclick={() => console.log('clicked')}>Test</Button>
</div>
{/snippet}
<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, i (i)}
<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 icon={mdiCheckDecagram} size="18" class="text-success" />
{/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>
<Switch checked={plugin.isEnabled} id={plugin.name} title="Enabled" />
</section>
{/each}
</div>
</section>
</AdminPageLayout>

View File

@@ -0,0 +1,16 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const plugins = [];
const $t = await getFormatter();
return {
plugins,
meta: {
title: $t('plugins'),
},
};
}) satisfies PageLoad;