335 lines
12 KiB
Go
335 lines
12 KiB
Go
package pluginhost
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
|
"golang.org/x/time/rate"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/events"
|
|
)
|
|
|
|
// EventPublisher is the subset of *events.Hub required by RuntimeHostServer.
|
|
// Defined as an interface so tests can supply a fake.
|
|
type EventPublisher interface {
|
|
Publish(ctx context.Context, env events.Envelope) error
|
|
}
|
|
|
|
// LibraryRecord is the wire shape for libraries returned to plugins. Mirrors
|
|
// the proto Library message.
|
|
type LibraryRecord struct {
|
|
ID string
|
|
Name string
|
|
MediaType string // movie | tv | mixed
|
|
}
|
|
|
|
// LibraryLister returns libraries visible to a user (or all when userID is "").
|
|
type LibraryLister interface {
|
|
ListLibraries(ctx context.Context, userID string) ([]LibraryRecord, error)
|
|
}
|
|
|
|
// LibraryPresenceRecord is a single host catalog match returned by a
|
|
// CatalogPresenceLookup. The plugin sees it as proto MediaPresence.
|
|
type LibraryPresenceRecord struct {
|
|
ExternalID string
|
|
MediaID string
|
|
LibraryID string
|
|
Title string
|
|
}
|
|
|
|
// CatalogPresenceLookup answers "which of these external IDs do we already
|
|
// have?" for a batch of IDs. v1 supports provider "tmdb" only; other values
|
|
// should return the empty result without error.
|
|
type CatalogPresenceLookup interface {
|
|
LookupByExternalIDs(ctx context.Context, provider, mediaType string, ids []string) ([]LibraryPresenceRecord, error)
|
|
}
|
|
|
|
// InstalledPluginRecord is the host-side shape returned to plugins for peer
|
|
// discovery.
|
|
type InstalledPluginRecord struct {
|
|
InstallationID int
|
|
PluginID string
|
|
Version string
|
|
Enabled bool
|
|
Capabilities []*pluginv1.CapabilityDescriptor
|
|
}
|
|
|
|
// InstalledPluginLister returns installed plugins and their capability
|
|
// descriptors for RuntimeHost.ListInstalledPlugins.
|
|
type InstalledPluginLister interface {
|
|
ListInstalledPlugins(ctx context.Context) ([]InstalledPluginRecord, error)
|
|
}
|
|
|
|
// InstalledPluginListerFunc adapts a plain function to InstalledPluginLister.
|
|
type InstalledPluginListerFunc func(ctx context.Context) ([]InstalledPluginRecord, error)
|
|
|
|
func (f InstalledPluginListerFunc) ListInstalledPlugins(ctx context.Context) ([]InstalledPluginRecord, error) {
|
|
return f(ctx)
|
|
}
|
|
|
|
// GlobalConfigSetter persists a global config entry for a plugin installation.
|
|
type GlobalConfigSetter interface {
|
|
SetGlobalConfigEntry(ctx context.Context, installationID int, key string, value map[string]any) error
|
|
}
|
|
|
|
// GlobalConfigSetterFunc adapts a plain function to GlobalConfigSetter.
|
|
type GlobalConfigSetterFunc func(ctx context.Context, installationID int, key string, value map[string]any) error
|
|
|
|
func (f GlobalConfigSetterFunc) SetGlobalConfigEntry(ctx context.Context, installationID int, key string, value map[string]any) error {
|
|
return f(ctx, installationID, key, value)
|
|
}
|
|
|
|
// DefaultPublishEventRatePerSec is the default maximum number of events a
|
|
// plugin may publish per second. It also serves as the burst size so a plugin
|
|
// can fire a short burst at a higher rate before being throttled.
|
|
const DefaultPublishEventRatePerSec = 100
|
|
|
|
// RuntimeHostServer is the gRPC server that plugins call back into via the
|
|
// go-plugin broker. Each plugin instance gets its own RuntimeHostServer so
|
|
// the pluginID is fixed at construction.
|
|
type RuntimeHostServer struct {
|
|
pluginv1.UnimplementedRuntimeHostServer
|
|
publisher EventPublisher
|
|
libs LibraryLister
|
|
catalog CatalogPresenceLookup
|
|
pluginID string
|
|
limiter *rate.Limiter
|
|
|
|
installedPlugins InstalledPluginLister
|
|
configSetter GlobalConfigSetter
|
|
installationID int
|
|
}
|
|
|
|
// NewRuntimeHostServer constructs a RuntimeHostServer bound to the given
|
|
// pluginID. The pluginID is server-stamped on all published events so plugins
|
|
// cannot forge core event names.
|
|
func NewRuntimeHostServer(publisher EventPublisher, libs LibraryLister, pluginID string) *RuntimeHostServer {
|
|
return &RuntimeHostServer{
|
|
publisher: publisher,
|
|
libs: libs,
|
|
pluginID: pluginID,
|
|
limiter: rate.NewLimiter(rate.Limit(DefaultPublishEventRatePerSec), DefaultPublishEventRatePerSec),
|
|
}
|
|
}
|
|
|
|
// NewRuntimeHostServerWithRate is like NewRuntimeHostServer but installs a
|
|
// caller-specified rate limit (events/sec, also used as burst). Pass perSec<=0
|
|
// to fall back to DefaultPublishEventRatePerSec.
|
|
func NewRuntimeHostServerWithRate(publisher EventPublisher, libs LibraryLister, pluginID string, perSec int) *RuntimeHostServer {
|
|
if perSec <= 0 {
|
|
perSec = DefaultPublishEventRatePerSec
|
|
}
|
|
return &RuntimeHostServer{
|
|
publisher: publisher,
|
|
libs: libs,
|
|
pluginID: pluginID,
|
|
limiter: rate.NewLimiter(rate.Limit(perSec), perSec),
|
|
}
|
|
}
|
|
|
|
// NewRuntimeHostServerWithCatalog is like NewRuntimeHostServer but also
|
|
// accepts a CatalogPresenceLookup. Use this when the host can answer
|
|
// CheckMediaPresence; passing nil makes presence queries return empty.
|
|
func NewRuntimeHostServerWithCatalog(publisher EventPublisher, libs LibraryLister, catalog CatalogPresenceLookup, pluginID string) *RuntimeHostServer {
|
|
s := NewRuntimeHostServer(publisher, libs, pluginID)
|
|
s.catalog = catalog
|
|
return s
|
|
}
|
|
|
|
// NewRuntimeHostServerWithServices is like NewRuntimeHostServerWithCatalog but
|
|
// also enables peer discovery and plugin-owned config persistence.
|
|
func NewRuntimeHostServerWithServices(
|
|
publisher EventPublisher,
|
|
libs LibraryLister,
|
|
catalog CatalogPresenceLookup,
|
|
installedPlugins InstalledPluginLister,
|
|
configSetter GlobalConfigSetter,
|
|
pluginID string,
|
|
installationID int,
|
|
) *RuntimeHostServer {
|
|
s := NewRuntimeHostServerWithCatalog(publisher, libs, catalog, pluginID)
|
|
s.installedPlugins = installedPlugins
|
|
s.configSetter = configSetter
|
|
s.installationID = installationID
|
|
return s
|
|
}
|
|
|
|
// PublishEvent auto-prefixes the plugin's event name with "plugin.<plugin_id>."
|
|
// and forwards to the EventPublisher on events.ChannelPlugins. The plugin ID
|
|
// is server-stamped (not taken from the request) so plugins cannot forge
|
|
// core event names by crafting a malicious event_name.
|
|
func (s *RuntimeHostServer) PublishEvent(ctx context.Context, req *pluginv1.PublishEventRequest) (*pluginv1.PublishEventResponse, error) {
|
|
if s.limiter != nil && !s.limiter.Allow() {
|
|
return nil, fmt.Errorf("rate limit exceeded for plugin %q", s.pluginID)
|
|
}
|
|
name := strings.TrimSpace(req.GetEventName())
|
|
if name == "" {
|
|
return nil, fmt.Errorf("event_name is required")
|
|
}
|
|
if s.pluginID == "" {
|
|
return nil, fmt.Errorf("server: plugin id not bound")
|
|
}
|
|
if s.publisher == nil {
|
|
return nil, fmt.Errorf("server: event publisher not configured")
|
|
}
|
|
|
|
var payload json.RawMessage
|
|
if p := req.GetPayload(); p != nil {
|
|
raw, err := p.MarshalJSON()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode payload: %w", err)
|
|
}
|
|
payload = raw
|
|
}
|
|
|
|
prefixed := "plugin." + s.pluginID + "." + name
|
|
env := events.Envelope{
|
|
Channel: events.ChannelPlugins,
|
|
Event: prefixed,
|
|
Data: payload,
|
|
}
|
|
if err := s.publisher.Publish(ctx, env); err != nil {
|
|
return nil, fmt.Errorf("publish: %w", err)
|
|
}
|
|
return &pluginv1.PublishEventResponse{}, nil
|
|
}
|
|
|
|
// PublishEventTo is like PublishEvent but restricts delivery to subscribers
|
|
// belonging to the target plugin_id.
|
|
func (s *RuntimeHostServer) PublishEventTo(ctx context.Context, req *pluginv1.PublishEventToRequest) (*pluginv1.PublishEventToResponse, error) {
|
|
if s.limiter != nil && !s.limiter.Allow() {
|
|
return nil, fmt.Errorf("rate limit exceeded for plugin %q", s.pluginID)
|
|
}
|
|
name := strings.TrimSpace(req.GetEventName())
|
|
if name == "" {
|
|
return nil, fmt.Errorf("event_name is required")
|
|
}
|
|
targetPluginID := strings.TrimSpace(req.GetTargetPluginId())
|
|
if targetPluginID == "" {
|
|
return nil, fmt.Errorf("target_plugin_id is required")
|
|
}
|
|
if s.pluginID == "" {
|
|
return nil, fmt.Errorf("server: plugin id not bound")
|
|
}
|
|
if s.publisher == nil {
|
|
return nil, fmt.Errorf("server: event publisher not configured")
|
|
}
|
|
|
|
var payload json.RawMessage
|
|
if p := req.GetPayload(); p != nil {
|
|
raw, err := p.MarshalJSON()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode payload: %w", err)
|
|
}
|
|
payload = raw
|
|
}
|
|
|
|
env := events.Envelope{
|
|
Channel: events.ChannelPlugins,
|
|
Event: "plugin." + s.pluginID + "." + name,
|
|
Data: payload,
|
|
TargetPluginID: targetPluginID,
|
|
}
|
|
if err := s.publisher.Publish(ctx, env); err != nil {
|
|
return nil, fmt.Errorf("publish: %w", err)
|
|
}
|
|
return &pluginv1.PublishEventToResponse{}, nil
|
|
}
|
|
|
|
// ListLibraries delegates to the LibraryLister, mapping the userID from the
|
|
// request through to the underlying data source.
|
|
func (s *RuntimeHostServer) ListLibraries(ctx context.Context, req *pluginv1.ListLibrariesRequest) (*pluginv1.ListLibrariesResponse, error) {
|
|
if s.libs == nil {
|
|
return &pluginv1.ListLibrariesResponse{}, nil
|
|
}
|
|
rows, err := s.libs.ListLibraries(ctx, req.GetUserId())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list libraries: %w", err)
|
|
}
|
|
resp := &pluginv1.ListLibrariesResponse{Libraries: make([]*pluginv1.Library, 0, len(rows))}
|
|
for _, r := range rows {
|
|
resp.Libraries = append(resp.Libraries, &pluginv1.Library{
|
|
Id: r.ID,
|
|
Name: r.Name,
|
|
MediaType: r.MediaType,
|
|
})
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// ListInstalledPlugins returns installed plugins and their advertised
|
|
// capabilities for peer discovery.
|
|
func (s *RuntimeHostServer) ListInstalledPlugins(ctx context.Context, _ *pluginv1.ListInstalledPluginsRequest) (*pluginv1.ListInstalledPluginsResponse, error) {
|
|
if s.installedPlugins == nil {
|
|
return &pluginv1.ListInstalledPluginsResponse{}, nil
|
|
}
|
|
rows, err := s.installedPlugins.ListInstalledPlugins(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list installed plugins: %w", err)
|
|
}
|
|
resp := &pluginv1.ListInstalledPluginsResponse{Plugins: make([]*pluginv1.InstalledPlugin, 0, len(rows))}
|
|
for _, row := range rows {
|
|
resp.Plugins = append(resp.Plugins, &pluginv1.InstalledPlugin{
|
|
InstallationId: int64(row.InstallationID),
|
|
PluginId: row.PluginID,
|
|
Version: row.Version,
|
|
Enabled: row.Enabled,
|
|
Capabilities: row.Capabilities,
|
|
})
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// SetGlobalConfigEntry persists a plugin-owned global config entry for this
|
|
// plugin installation.
|
|
func (s *RuntimeHostServer) SetGlobalConfigEntry(ctx context.Context, req *pluginv1.SetGlobalConfigEntryRequest) (*pluginv1.SetGlobalConfigEntryResponse, error) {
|
|
key := strings.TrimSpace(req.GetKey())
|
|
if key == "" {
|
|
return nil, fmt.Errorf("key is required")
|
|
}
|
|
if s.installationID == 0 {
|
|
return nil, fmt.Errorf("server: installation id not bound")
|
|
}
|
|
if s.configSetter == nil {
|
|
return nil, fmt.Errorf("server: config setter not configured")
|
|
}
|
|
value := map[string]any{}
|
|
if req.GetValue() != nil {
|
|
value = req.GetValue().AsMap()
|
|
}
|
|
if err := s.configSetter.SetGlobalConfigEntry(ctx, s.installationID, key, value); err != nil {
|
|
return nil, fmt.Errorf("set global config entry: %w", err)
|
|
}
|
|
return &pluginv1.SetGlobalConfigEntryResponse{}, nil
|
|
}
|
|
|
|
// CheckMediaPresence delegates to the configured CatalogPresenceLookup.
|
|
// Returns the empty list when no catalog is configured.
|
|
func (s *RuntimeHostServer) CheckMediaPresence(ctx context.Context, req *pluginv1.CheckMediaPresenceRequest) (*pluginv1.CheckMediaPresenceResponse, error) {
|
|
if len(req.GetIds()) > 100 {
|
|
return nil, fmt.Errorf("ids: too many (%d), max 100", len(req.GetIds()))
|
|
}
|
|
if s.catalog == nil {
|
|
return &pluginv1.CheckMediaPresenceResponse{}, nil
|
|
}
|
|
rows, err := s.catalog.LookupByExternalIDs(ctx, req.GetProvider(), req.GetMediaType(), req.GetIds())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lookup: %w", err)
|
|
}
|
|
resp := &pluginv1.CheckMediaPresenceResponse{
|
|
Present: make([]*pluginv1.MediaPresence, 0, len(rows)),
|
|
}
|
|
for _, r := range rows {
|
|
resp.Present = append(resp.Present, &pluginv1.MediaPresence{
|
|
ExternalId: r.ExternalID,
|
|
MediaId: r.MediaID,
|
|
LibraryId: r.LibraryID,
|
|
Title: r.Title,
|
|
})
|
|
}
|
|
return resp, nil
|
|
}
|