From 66a4146fc257d31bdfea7f55c1156718ef4f744a Mon Sep 17 00:00:00 2001 From: fluxis Date: Tue, 9 Jun 2026 11:31:52 -0400 Subject: [PATCH] fix(metadata): exclude providers from content levels they don't declare (#106) ResolveChain falls back to every enabled metadata provider when a library + content-level has no enabled chain entry. That fallback was media-type blind: a provider declaring default_priority only for an unrelated level (e.g. an audiobook provider declaring {"audiobook": N}) was kept in the list (merely sorted last) and invoked for video content levels. In production this made silo.audiobook-metadata hammer external audiobook APIs with anime/movie/series titles every scheduled enrichment pass (MatchWorker, 30s) for the season/episode levels that had no enabled chain entry. Disabling the chain entries did not help because the fallback never consults them; only disabling the installation removed it from the global set. Treat a non-empty default_priority map as the provider enumerating the content levels it supports: in resolveEnabledProvidersByPriority, exclude providers whose declared map omits the requested level instead of ranking them last. Providers that declare no default_priority make no claim and stay eligible everywhere (legacy behavior). Fixes #105 Co-authored-by: Claude Opus 4.8 (1M context) --- internal/metadata/chain.go | 81 +++++++++++++++++++++++++-------- internal/metadata/chain_test.go | 59 ++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 19 deletions(-) create mode 100644 internal/metadata/chain_test.go diff --git a/internal/metadata/chain.go b/internal/metadata/chain.go index a3c9c608..2d5dfced 100644 --- a/internal/metadata/chain.go +++ b/internal/metadata/chain.go @@ -303,9 +303,21 @@ func resolveEnabledProvidersByPriority( priority int } - items := make([]ranked, len(caps)) - for i, c := range caps { - items[i] = ranked{cap: c, priority: LookupDefaultPriority(ctx, pool, c.PluginInstallationID, contentLevel)} + // 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) + 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 { @@ -326,9 +338,27 @@ func resolveEnabledProvidersByPriority( return buildProviders(ctx, sorted, resolver, pool), nil } -// 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, contentLevel string) int { +// 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 metadata_provider.v1 capability, or nil if absent. +func lookupCapabilityMetadata(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int) []byte { var metadataJSON []byte err := pool.QueryRow(ctx, `SELECT metadata FROM plugin_capabilities @@ -337,21 +367,37 @@ func LookupDefaultPriority(ctx context.Context, pool *pgxpool.Pool, pluginInstal pluginInstallationID, ).Scan(&metadataJSON) if err != nil { - return 0 + return nil } + return metadataJSON +} - return extractDefaultPriority(metadataJSON, contentLevel) +// 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, contentLevel string) int { + return extractDefaultPriority(lookupCapabilityMetadata(ctx, pool, pluginInstallationID), 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 +} + +// 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 0 + return nil, false } - // default_priority may be at the top level or nested inside a "metadata" sub-object - // (plugin capability metadata wraps plugin-declared fields in a "metadata" envelope). dpRaw, ok := meta["default_priority"] if !ok { if innerRaw, innerOK := meta["metadata"]; innerOK { @@ -360,18 +406,15 @@ func extractDefaultPriority(metadataJSON []byte, contentLevel string) int { dpRaw, ok = inner["default_priority"] } } - if !ok { - return 0 - } + } + if !ok { + return nil, false } var dpMap map[string]float64 if err := json.Unmarshal(dpRaw, &dpMap); err != nil { - return 0 + return nil, false } - if v, ok := dpMap[contentLevel]; ok && v > 0 { - return int(v) - } - return 0 + return dpMap, len(dpMap) > 0 } // ListEnabledMetadataCapabilities returns all metadata_provider.v1 capabilities diff --git a/internal/metadata/chain_test.go b/internal/metadata/chain_test.go new file mode 100644 index 00000000..2f439723 --- /dev/null +++ b/internal/metadata/chain_test.go @@ -0,0 +1,59 @@ +package metadata + +import "testing" + +// Capability metadata as stored in plugin_capabilities.metadata: plugin-declared +// fields are wrapped in a "metadata" envelope. +const ( + audiobookCapMetadata = `{"metadata":{"default_priority":{"audiobook":2}},"display_name":"Audiobook Metadata"}` + tmdbCapMetadata = `{"metadata":{"default_priority":{"movie":2,"season":3,"series":3,"episode":3}},"display_name":"TMDB"}` + tvdbCapMetadata = `{"metadata":{"default_priority":{"season":2,"series":2,"episode":2}},"display_name":"TVDB"}` +) + +func TestProviderSupportsLevel(t *testing.T) { + cases := []struct { + name string + metadataJSON string + contentLevel string + want bool + }{ + // An audiobook-only provider must NOT be pulled into video content + // levels by the chain-less global fallback. This is the regression + // that let silo.audiobook-metadata hammer audiobook APIs with + // anime/movie/series titles. + {"audiobook provider excluded from episode", audiobookCapMetadata, "episode", false}, + {"audiobook provider excluded from series", audiobookCapMetadata, "series", false}, + {"audiobook provider excluded from season", audiobookCapMetadata, "season", false}, + {"audiobook provider excluded from movie", audiobookCapMetadata, "movie", false}, + {"audiobook provider included for audiobook", audiobookCapMetadata, "audiobook", true}, + + // Providers that declare a level stay eligible for it. + {"tmdb included for movie", tmdbCapMetadata, "movie", true}, + {"tmdb included for season", tmdbCapMetadata, "season", true}, + {"tmdb included for episode", tmdbCapMetadata, "episode", true}, + + // tvdb declares no movie level -> excluded from the movie fallback. + {"tvdb excluded from movie", tvdbCapMetadata, "movie", false}, + {"tvdb included for series", tvdbCapMetadata, "series", true}, + + // A provider that declares no default_priority makes no claim and stays + // eligible everywhere (legacy behavior), ranked last by priority 0. + {"no metadata is eligible", ``, "episode", true}, + {"empty object is eligible", `{}`, "episode", true}, + {"empty priority map is eligible", `{"metadata":{"default_priority":{}}}`, "episode", true}, + {"malformed json is eligible (fail-open)", `{not json`, "episode", true}, + + // A declared level with non-positive priority is not supported. + {"zero priority level not supported", `{"metadata":{"default_priority":{"episode":0}}}`, "episode", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := providerSupportsLevel([]byte(tc.metadataJSON), tc.contentLevel) + if got != tc.want { + t.Fatalf("providerSupportsLevel(%q, %q) = %v, want %v", + tc.metadataJSON, tc.contentLevel, got, tc.want) + } + }) + } +}