* 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>
96 lines
4.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|