Files
silo-server/internal/metadata/chain.go
T
a26b2de454 fix(metadata): stop specialist providers hijacking new library chains (#294)
* fix(metadata): seed specialist providers off and scope chains to declared levels

New library provider chains were seeded from every enabled metadata provider,
ordered purely by each plugin's declared default_priority and enabled whenever
that priority was > 0. Two consequences:

- A specialist provider (e.g. silo.sportarr, which declares series/season/
  episode) could out-rank the general providers and land at position 1,
  enabled, on every new TV series library.
- Single-purpose providers that declare only their own level (audiobook /
  ebook / manga metadata) were still attached as disabled rows to series and
  movie libraries, cluttering the chain editor with providers that cannot
  serve that content.

Introduce a `default_enabled` capability-metadata flag (defaults to true, so
every existing plugin is unaffected). A provider sets it false to be seeded
installed-but-disabled while keeping its declared priority, so a user can opt
in per-library and it slots in where the manifest intends instead of jumping
to the top. At the same time, seedDefaultChain and AppendProviderToAllChains
now drop providers that do not declare a content level, reusing the same
providerSupportsLevel rule as the chain-less fallback (issue #106).

LookupSeedPlacement resolves support/priority/enabled with a single metadata
fetch. buildSeededChainEntries is extracted as a pure, unit-tested helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): standardize metadata provider slug casing in library chain editor

The library provider-chain editor showed the same provider differently
depending on where the chain came from: a freshly defaulted chain used the
capability display name ("TMDB"), while a chain loaded from the server used
the capability id ("tmdb", which the API returns as provider_slug). So a
provider read one way before saving and another after, and differed between
library types depending on which levels already had a saved chain.

Standardize on the capability id everywhere (matches the server's
provider_slug and the mono/slug styling). Extract the provider mapping into a
pure, unit-tested metadataProvidersFromInstallations helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): mirror server seeding rules in the library form's default chain

The form builds its own default chain client-side, and any touch (including
changing the library type on create, the normal path for a series library)
marks it dirty and POSTs it after create — replacing the server-seeded chain.
That chain still enabled every provider with a declared priority and listed
unsupported providers as disabled rows, so the server-side fix evaporated on
the UI create path.

buildDefaultLevelChains now applies the same rules as buildSeededChainEntries:
providers that don't declare the level are dropped, a declaring provider is
enabled only if it doesn't opt out via default_enabled, and a legacy catch-all
(no declared levels) is parked last, disabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(web,api): serve default provider chains from the server

Replace the form's client-side reimplementation of the seeding rules with a
new additive endpoint, GET /api/v1/libraries/provider-defaults?library_type=X,
which returns the exact chain seedDefaultChain would write for that type.
The create form now renders those server-computed defaults, changing the
library type just refetches them (no longer marking the chain dirty), and a
create with an untouched chain lets the server-seeded chain stand instead of
writing one back. Editing an existing library uses the same defaults to fill
levels its saved chain doesn't cover.

Types the server seeds no metadata levels for (e.g. podcasts) return an empty
levels map rather than an error.

This removes buildDefaultLevelChains / metadataProvidersFromInstallations and
the default_priority/default_enabled manifest parsing from the frontend — one
source of truth for default ordering and enablement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): show a loading state in the provider chain editor

While the server chain (for an existing library) or the type's defaults are
still in flight, the editor rendered empty provider lists for a moment. Show
a spinner row instead; local edits always render immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-07 09:42:41 -04:00

636 lines
22 KiB
Go

package metadata
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// InstallationEnabledChecker answers whether a plugin installation is enabled.
// It mirrors the structural-dependency style of pluginMetadataResolver /
// PluginResolverAdapter so the metadata package can consult the plugins
// service's in-memory installation cache without importing (and creating an
// import cycle with) the plugins package. *plugins.Service satisfies it via
// IsInstallationEnabled.
type InstallationEnabledChecker interface {
IsInstallationEnabled(ctx context.Context, installationID int) (bool, error)
}
// installationEnabled reports whether a plugin installation is enabled. When a
// checker is supplied it is served from the plugins service's cache (no DB
// read); when nil it falls back to a direct pool query so tests and non-plugin
// builds keep working unchanged.
func installationEnabled(
ctx context.Context,
checker InstallationEnabledChecker,
pool *pgxpool.Pool,
installationID int,
) (bool, error) {
if checker != nil {
return checker.IsInstallationEnabled(ctx, installationID)
}
var enabled bool
err := pool.QueryRow(ctx,
"SELECT enabled FROM plugin_installations WHERE id = $1",
installationID,
).Scan(&enabled)
return enabled, err
}
// ChainEntry represents a single entry in a library's provider chain.
type ChainEntry struct {
PluginInstallationID int
CapabilityID string
CapabilityType string // always "metadata_provider.v1" for now
ContentLevel string // "movie", "series", "season", "episode", or "" (legacy)
Priority int
Enabled bool
}
// ChainRepository provides operations for the library_provider_chains table.
type ChainRepository struct {
pool *pgxpool.Pool
}
// NewChainRepository creates a new ChainRepository.
func NewChainRepository(pool *pgxpool.Pool) *ChainRepository {
return &ChainRepository{pool: pool}
}
// Pool returns the underlying connection pool.
func (r *ChainRepository) Pool() *pgxpool.Pool {
return r.pool
}
// SetChain replaces the entire provider chain for a given media folder.
func (r *ChainRepository) SetChain(ctx context.Context, folderID int, entries []ChainEntry) error {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("beginning chain transaction: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
_, err = tx.Exec(ctx, "DELETE FROM library_provider_chains WHERE media_folder_id = $1", folderID)
if err != nil {
return fmt.Errorf("deleting existing chain: %w", err)
}
for _, entry := range entries {
capType := entry.CapabilityType
if capType == "" {
capType = "metadata_provider.v1"
}
_, err = tx.Exec(ctx,
`INSERT INTO library_provider_chains (media_folder_id, plugin_installation_id, capability_id, capability_type, content_level, priority, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
folderID, entry.PluginInstallationID, entry.CapabilityID, capType, entry.ContentLevel, entry.Priority, entry.Enabled,
)
if err != nil {
return fmt.Errorf("inserting chain entry (install=%d, cap=%s, level=%s, priority=%d): %w",
entry.PluginInstallationID, entry.CapabilityID, entry.ContentLevel, entry.Priority, err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("committing chain transaction: %w", err)
}
return nil
}
// GetChain returns entries for a specific content level. If no per-level entries
// exist, falls back to legacy flat entries (content_level = ”).
func (r *ChainRepository) GetChain(ctx context.Context, folderID int, contentLevel string) ([]ChainEntry, error) {
rows, err := r.pool.Query(ctx,
`SELECT plugin_installation_id, capability_id, capability_type, content_level, priority, enabled
FROM library_provider_chains
WHERE media_folder_id = $1 AND content_level = $2
ORDER BY priority ASC`,
folderID, contentLevel,
)
if err != nil {
return nil, fmt.Errorf("querying chain: %w", err)
}
defer rows.Close()
var entries []ChainEntry
for rows.Next() {
var e ChainEntry
if err := rows.Scan(&e.PluginInstallationID, &e.CapabilityID, &e.CapabilityType, &e.ContentLevel, &e.Priority, &e.Enabled); err != nil {
return nil, fmt.Errorf("scanning chain entry: %w", err)
}
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating chain rows: %w", err)
}
// Fall back to legacy flat chain if no per-level entries exist.
if len(entries) == 0 && contentLevel != "" {
return r.GetChain(ctx, folderID, "")
}
if entries == nil {
entries = []ChainEntry{}
}
return entries, nil
}
// GetAllChainEntries returns every chain entry for a folder, across all content levels.
func (r *ChainRepository) GetAllChainEntries(ctx context.Context, folderID int) ([]ChainEntry, error) {
rows, err := r.pool.Query(ctx,
`SELECT plugin_installation_id, capability_id, capability_type, content_level, priority, enabled
FROM library_provider_chains
WHERE media_folder_id = $1
ORDER BY content_level, priority ASC`,
folderID,
)
if err != nil {
return nil, fmt.Errorf("querying all chain entries: %w", err)
}
defer rows.Close()
var entries []ChainEntry
for rows.Next() {
var e ChainEntry
if err := rows.Scan(&e.PluginInstallationID, &e.CapabilityID, &e.CapabilityType, &e.ContentLevel, &e.Priority, &e.Enabled); err != nil {
return nil, fmt.Errorf("scanning chain entry: %w", err)
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// DeleteChain removes all provider chain entries for a given media folder.
func (r *ChainRepository) DeleteChain(ctx context.Context, folderID int) error {
_, err := r.pool.Exec(ctx, "DELETE FROM library_provider_chains WHERE media_folder_id = $1", folderID)
if err != nil {
return fmt.Errorf("deleting chain: %w", err)
}
return nil
}
// AppendProviderToAllChains adds a provider to every existing library chain
// (per content level) that it serves and doesn't already include it. The
// placement callback resolves the plugin's manifest intent for a content level:
// levels it does not support are skipped entirely, and it is appended enabled
// only when it declares the level and opts into default_enabled.
func (r *ChainRepository) AppendProviderToAllChains(
ctx context.Context,
pluginInstallationID int,
capabilityID string,
placement func(contentLevel string) SeedPlacement,
) error {
// Find every distinct (folder, level) pair that has chain entries.
rows, err := r.pool.Query(ctx,
`SELECT DISTINCT media_folder_id, content_level
FROM library_provider_chains`)
if err != nil {
return fmt.Errorf("listing chain groups: %w", err)
}
defer rows.Close()
type chainGroup struct {
folderID int
contentLevel string
}
var groups []chainGroup
for rows.Next() {
var g chainGroup
if err := rows.Scan(&g.folderID, &g.contentLevel); err != nil {
return fmt.Errorf("scanning chain group: %w", err)
}
groups = append(groups, g)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterating chain groups: %w", err)
}
for _, g := range groups {
// Only attach the provider to levels it actually serves. A single-purpose
// provider (e.g. audiobook/ebook/manga metadata, or a sports provider that
// only declares series levels) must not clutter every library's chain with
// a disabled row for content it cannot handle.
p := placement(g.contentLevel)
if !p.SupportsLevel {
continue
}
// Check if the provider is already in this chain.
var exists bool
err := r.pool.QueryRow(ctx,
`SELECT EXISTS(
SELECT 1 FROM library_provider_chains
WHERE media_folder_id = $1 AND content_level = $2
AND plugin_installation_id = $3 AND capability_id = $4
)`, g.folderID, g.contentLevel, pluginInstallationID, capabilityID,
).Scan(&exists)
if err != nil {
return fmt.Errorf("checking chain membership: %w", err)
}
if exists {
continue
}
// Determine priority position (append after the last entry).
var maxPriority int
err = r.pool.QueryRow(ctx,
`SELECT COALESCE(MAX(priority), -1)
FROM library_provider_chains
WHERE media_folder_id = $1 AND content_level = $2`,
g.folderID, g.contentLevel,
).Scan(&maxPriority)
if err != nil {
return fmt.Errorf("getting max priority: %w", err)
}
// A provider joins an existing library disabled unless it declares this
// level and opts into being enabled by default. This keeps a specialist
// (default_enabled=false) one click away instead of silently taking over
// established libraries the moment it is installed.
enabled := p.DefaultPriority > 0 && p.DefaultEnabled
_, err = r.pool.Exec(ctx,
`INSERT INTO library_provider_chains (media_folder_id, plugin_installation_id, capability_id, capability_type, content_level, priority, enabled)
VALUES ($1, $2, $3, 'metadata_provider.v1', $4, $5, $6)
ON CONFLICT DO NOTHING`,
g.folderID, pluginInstallationID, capabilityID, g.contentLevel, maxPriority+1, enabled,
)
if err != nil {
return fmt.Errorf("appending provider (install=%d, cap=%s) to folder %d level %q: %w",
pluginInstallationID, capabilityID, g.folderID, g.contentLevel, err)
}
}
return nil
}
// ResolveChain builds the ordered list of Provider implementations for a given
// media folder and content level. If the folder has custom chain entries, those
// are used. Otherwise all enabled metadata provider capabilities are used,
// ordered by their plugin manifest default_priority for the given content level.
//
// Providers whose underlying plugin installation is disabled are silently skipped.
//
// The enabled-check falls back to a direct pool query. Callers on the hot path
// (MetadataService.resolveChainCached) use ResolveChainWithChecker to serve it
// from the plugins service's in-memory installation cache instead.
func ResolveChain(
ctx context.Context,
folderID int,
contentLevel string,
chainRepo *ChainRepository,
resolver pluginMetadataResolver,
) ([]Provider, error) {
return ResolveChainWithChecker(ctx, folderID, contentLevel, chainRepo, resolver, nil)
}
// ResolveChainWithChecker is ResolveChain with an optional
// InstallationEnabledChecker threaded through the enabled-check. A nil checker
// preserves the direct pool-query behavior, so existing callers and tests are
// unaffected.
func ResolveChainWithChecker(
ctx context.Context,
folderID int,
contentLevel string,
chainRepo *ChainRepository,
resolver pluginMetadataResolver,
checker InstallationEnabledChecker,
) ([]Provider, error) {
chainEntries, err := chainRepo.GetChain(ctx, folderID, contentLevel)
if err != nil {
return nil, fmt.Errorf("getting chain for folder %d: %w", folderID, err)
}
// Filter to only enabled entries.
var enabledEntries []ChainEntry
for _, e := range chainEntries {
if e.Enabled {
enabledEntries = append(enabledEntries, e)
}
}
chainEntries = enabledEntries
if len(chainEntries) > 0 {
return resolveChainEntries(ctx, chainEntries, resolver, chainRepo.pool, checker), nil
}
providers, err := resolveEnabledProvidersByPriority(ctx, contentLevel, resolver, chainRepo.pool, checker)
if err != nil {
return nil, err
}
return providers, nil
}
// CapabilityInfo holds the fields needed to construct a provider from plugin tables.
type CapabilityInfo struct {
PluginInstallationID int
CapabilityID string
DisplayName string
}
// resolveEnabledProviders returns all enabled providers in installation ID order.
// Used by callers that don't have a content-level context (e.g. person refresh).
func resolveEnabledProviders(
ctx context.Context,
resolver pluginMetadataResolver,
pool *pgxpool.Pool,
checker InstallationEnabledChecker,
) ([]Provider, error) {
caps, err := ListEnabledMetadataCapabilities(ctx, pool)
if err != nil {
return nil, err
}
return buildProviders(ctx, caps, resolver, pool, checker), nil
}
// resolveEnabledProvidersByPriority returns all enabled providers sorted by
// their plugin manifest default_priority for the given content level. Providers
// without a declared priority are placed last (sorted by installation ID as a tiebreaker).
func resolveEnabledProvidersByPriority(
ctx context.Context,
contentLevel string,
resolver pluginMetadataResolver,
pool *pgxpool.Pool,
checker InstallationEnabledChecker,
) ([]Provider, error) {
caps, err := ListEnabledMetadataCapabilities(ctx, pool)
if err != nil {
return nil, err
}
type ranked struct {
cap CapabilityInfo
priority int
}
// Only providers that support contentLevel participate in the chain-less
// fallback. A provider declaring a non-empty default_priority map that omits
// this level is excluded outright (not merely ranked last), so a
// single-purpose provider is never invoked for content it does not handle.
items := make([]ranked, 0, len(caps))
for _, c := range caps {
metadataJSON := lookupCapabilityMetadata(ctx, pool, c.PluginInstallationID, c.CapabilityID)
if !providerSupportsLevel(metadataJSON, contentLevel) {
slog.Debug("skipping metadata provider: does not declare support for content level",
"installation_id", c.PluginInstallationID,
"capability_id", c.CapabilityID,
"content_level", contentLevel)
continue
}
items = append(items, ranked{cap: c, priority: extractDefaultPriority(metadataJSON, contentLevel)})
}
sort.SliceStable(items, func(i, j int) bool {
pi, pj := items[i].priority, items[j].priority
if (pi == 0) != (pj == 0) {
return pi != 0
}
if pi != pj {
return pi < pj
}
return items[i].cap.PluginInstallationID < items[j].cap.PluginInstallationID
})
sorted := make([]CapabilityInfo, len(items))
for i, item := range items {
sorted[i] = item.cap
}
return buildProviders(ctx, sorted, resolver, pool, checker), nil
}
// providerSupportsLevel reports whether a metadata provider should participate
// in the chain-less global fallback for contentLevel. A provider that declares
// a non-empty default_priority map is treated as enumerating the content levels
// it supports: it is eligible only for levels present in that map with a
// positive priority. A provider that declares no default_priority makes no
// claim and stays eligible for every level (legacy behavior), ranked last.
//
// This is what keeps a single-purpose provider (e.g. an audiobook metadata
// provider declaring only {"audiobook": N}) from being pulled into video
// content levels when a library has no enabled chain entry for that level.
func providerSupportsLevel(metadataJSON []byte, contentLevel string) bool {
levels, declared := declaredPriorityLevels(metadataJSON)
if !declared {
return true
}
return levels[contentLevel] > 0
}
// lookupCapabilityMetadata returns the raw plugin_capabilities.metadata JSON for
// a provider's exact metadata_provider.v1 capability, or nil if absent.
func lookupCapabilityMetadata(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID string) []byte {
var metadataJSON []byte
err := pool.QueryRow(ctx,
`SELECT metadata FROM plugin_capabilities
WHERE plugin_installation_id = $1
AND capability_id = $2
AND capability_type = 'metadata_provider.v1'`,
pluginInstallationID,
capabilityID,
).Scan(&metadataJSON)
if err != nil {
return nil
}
return metadataJSON
}
// LookupDefaultPriority queries plugin_capabilities for a provider's declared
// default_priority at the given content level. Returns 0 if not found.
func LookupDefaultPriority(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID, contentLevel string) int {
return extractDefaultPriority(lookupCapabilityMetadata(ctx, pool, pluginInstallationID, capabilityID), contentLevel)
}
// extractDefaultPriority parses the default_priority for a content level from
// capability metadata JSON.
func extractDefaultPriority(metadataJSON []byte, contentLevel string) int {
levels, _ := declaredPriorityLevels(metadataJSON)
if v, ok := levels[contentLevel]; ok && v > 0 {
return int(v)
}
return 0
}
// extractDefaultEnabled reports whether a provider should be enabled by default
// when a chain is first seeded for a new library. It defaults to true when the
// flag is absent or unparseable, so every plugin predating this flag keeps its
// original seeded-enabled behavior. A specialist provider (e.g. a sports
// metadata source that should not compete with general providers on every
// library) sets metadata.default_enabled=false to be seeded installed-but-off,
// leaving it one click away for users who want it. The flag lives in the same
// "metadata" envelope as default_priority.
func extractDefaultEnabled(metadataJSON []byte) bool {
var meta map[string]json.RawMessage
if err := json.Unmarshal(metadataJSON, &meta); err != nil {
return true
}
raw, ok := meta["default_enabled"]
if !ok {
if innerRaw, innerOK := meta["metadata"]; innerOK {
var inner map[string]json.RawMessage
if err := json.Unmarshal(innerRaw, &inner); err == nil {
raw, ok = inner["default_enabled"]
}
}
}
if !ok {
return true
}
var enabled bool
if err := json.Unmarshal(raw, &enabled); err != nil {
return true
}
return enabled
}
// SeedPlacement captures how a provider's manifest wants it placed when a chain
// is first seeded for a content level: whether it handles the level at all, its
// declared priority, and whether it should be enabled by default.
type SeedPlacement struct {
SupportsLevel bool
DefaultPriority int
DefaultEnabled bool
}
// LookupSeedPlacement resolves a provider's seed placement for one content level
// from its capability manifest with a single metadata fetch. SupportsLevel uses
// the same rule as the chain-less fallback (providerSupportsLevel): a provider
// that enumerates levels is eligible only for the ones it lists; a provider that
// declares none stays eligible everywhere.
func LookupSeedPlacement(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID, contentLevel string) SeedPlacement {
md := lookupCapabilityMetadata(ctx, pool, pluginInstallationID, capabilityID)
return SeedPlacement{
SupportsLevel: providerSupportsLevel(md, contentLevel),
DefaultPriority: extractDefaultPriority(md, contentLevel),
DefaultEnabled: extractDefaultEnabled(md),
}
}
// declaredPriorityLevels parses a capability's default_priority map. The map may
// sit at the top level or inside a "metadata" envelope (plugin capability
// metadata wraps plugin-declared fields in a "metadata" sub-object). The second
// return value is true only when a non-empty map was found, i.e. the provider
// explicitly enumerates the content levels it supports.
func declaredPriorityLevels(metadataJSON []byte) (map[string]float64, bool) {
var meta map[string]json.RawMessage
if err := json.Unmarshal(metadataJSON, &meta); err != nil {
return nil, false
}
dpRaw, ok := meta["default_priority"]
if !ok {
if innerRaw, innerOK := meta["metadata"]; innerOK {
var inner map[string]json.RawMessage
if err := json.Unmarshal(innerRaw, &inner); err == nil {
dpRaw, ok = inner["default_priority"]
}
}
}
if !ok {
return nil, false
}
var dpMap map[string]float64
if err := json.Unmarshal(dpRaw, &dpMap); err != nil {
return nil, false
}
return dpMap, len(dpMap) > 0
}
// ListEnabledMetadataCapabilities returns all metadata_provider.v1 capabilities
// whose plugin installation is enabled.
func ListEnabledMetadataCapabilities(ctx context.Context, pool *pgxpool.Pool) ([]CapabilityInfo, error) {
rows, err := pool.Query(ctx,
`SELECT pc.plugin_installation_id, pc.capability_id,
COALESCE(pc.metadata->>'display_name', pc.capability_id)
FROM plugin_capabilities pc
JOIN plugin_installations pi ON pi.id = pc.plugin_installation_id
WHERE pc.capability_type = 'metadata_provider.v1'
AND pi.enabled = true
ORDER BY pc.plugin_installation_id`)
if err != nil {
return nil, fmt.Errorf("listing enabled metadata capabilities: %w", err)
}
defer rows.Close()
var caps []CapabilityInfo
for rows.Next() {
var c CapabilityInfo
if err := rows.Scan(&c.PluginInstallationID, &c.CapabilityID, &c.DisplayName); err != nil {
return nil, fmt.Errorf("scanning capability: %w", err)
}
caps = append(caps, c)
}
return caps, rows.Err()
}
// resolveChainEntries builds Provider instances from explicit chain entries,
// skipping providers whose plugin installation is disabled.
func resolveChainEntries(
ctx context.Context,
entries []ChainEntry,
resolver pluginMetadataResolver,
pool *pgxpool.Pool,
checker InstallationEnabledChecker,
) []Provider {
caps := make([]CapabilityInfo, 0, len(entries))
for _, e := range entries {
displayName := lookupCapabilityDisplayName(ctx, pool, e.PluginInstallationID, e.CapabilityID)
caps = append(caps, CapabilityInfo{
PluginInstallationID: e.PluginInstallationID,
CapabilityID: e.CapabilityID,
DisplayName: displayName,
})
}
return buildProviders(ctx, caps, resolver, pool, checker)
}
// buildProviders constructs Provider instances from capability info, skipping
// providers whose plugin installation is disabled.
func buildProviders(
ctx context.Context,
caps []CapabilityInfo,
resolver pluginMetadataResolver,
pool *pgxpool.Pool,
checker InstallationEnabledChecker,
) []Provider {
providers := make([]Provider, 0, len(caps))
for _, c := range caps {
enabled, err := installationEnabled(ctx, checker, pool, c.PluginInstallationID)
if err != nil || !enabled {
slog.Debug("skipping metadata provider: plugin installation disabled",
"installation_id", c.PluginInstallationID, "capability_id", c.CapabilityID)
continue
}
provider, err := NewPluginProviderFromCapability(c.PluginInstallationID, c.CapabilityID, c.DisplayName, resolver)
if err != nil {
slog.Warn("skipping metadata provider during chain resolution",
"installation_id", c.PluginInstallationID,
"capability_id", c.CapabilityID,
"error", err,
)
continue
}
providers = append(providers, provider)
}
return providers
}
// lookupCapabilityDisplayName retrieves the display name from plugin capability metadata.
func lookupCapabilityDisplayName(ctx context.Context, pool *pgxpool.Pool, installationID int, capabilityID string) string {
var displayName string
err := pool.QueryRow(ctx,
`SELECT COALESCE(metadata->>'display_name', $2)
FROM plugin_capabilities
WHERE plugin_installation_id = $1 AND capability_id = $2 AND capability_type = 'metadata_provider.v1'`,
installationID, capabilityID,
).Scan(&displayName)
if err != nil {
return capabilityID
}
return displayName
}