Files
silo-server/internal/metadata/chain_test.go
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

96 lines
4.0 KiB
Go

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)
}
})
}
}
func TestExtractDefaultEnabled(t *testing.T) {
cases := []struct {
name string
metadataJSON string
want bool
}{
// Absent flag defaults to true so every existing plugin is seeded
// enabled exactly as before this flag existed.
{"no metadata defaults enabled", ``, true},
{"empty object defaults enabled", `{}`, true},
{"tmdb without flag defaults enabled", tmdbCapMetadata, true},
{"malformed json defaults enabled (fail-open)", `{not json`, true},
// A specialist provider opts out of being auto-enabled. The flag lives
// in the same "metadata" envelope as default_priority.
{"opt-out inside envelope", `{"metadata":{"default_priority":{"series":50},"default_enabled":false}}`, false},
{"opt-out at top level", `{"default_enabled":false}`, false},
// An explicit true is honored (and is the default anyway).
{"explicit true inside envelope", `{"metadata":{"default_enabled":true}}`, true},
// A non-boolean value is ignored, falling back to the enabled default.
{"non-boolean value defaults enabled", `{"metadata":{"default_enabled":"nope"}}`, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := extractDefaultEnabled([]byte(tc.metadataJSON))
if got != tc.want {
t.Fatalf("extractDefaultEnabled(%q) = %v, want %v",
tc.metadataJSON, got, tc.want)
}
})
}
}