* feat(metadata): register builtin NFO provider and broaden parsing Phases A and B of the #216 local-NFO work, implemented test-first. Registration & hint-first identity (Phase A): - Migration seeds a reserved kind='builtin' silo.builtin installation and an 'nfo' metadata capability (default_enabled=false, priority 1 for movie/series) with a partial unique index and documented Down. - In-process builtin provider registry (internal/metadata/builtin.go); buildProviders returns the registered provider for builtin rows. - Guard rails keep the reserved row out of every plugin surface (user plugin-settings, installations list, image resolvers, preload, auto-update, store Delete, mutation handlers -> 409); silo.builtin is a reserved manifest id. - Startup sync materializes legacy content_level='' chains per level, then appends builtin capabilities disabled via AppendProviderToAllChains (idempotent); resolveEnabledProvidersBy priority now respects default_enabled=false. - NFO uniqueids seed the trusted-hint machinery via IdentityHintProvider with per-mode conflict policy (stored IDs win on scheduled refresh, NFO wins on manual refresh, Identify skips NFO); ID-less candidates are excluded from provider-priority tie-breaks and nfo never counts as corroboration. - Web chain-editor empty-state gate is now server-derived so builtin providers are reachable on plugin-less servers. Parser breadth & sidecar hardening (Phase B): - Parser covers the practical Kodi/Jellyfin field set for <movie> and <tvshow>: original title, tagline, runtime, dates, content rating, genres/studios/countries/tags, multi-source ratings with scale normalization, cast with roles/order, director/credits. Empty collections stay nil so merge early-returns apply. - findNFO parses candidates and falls through on read/parse failure or root-type mismatch, so a stray movie.nfo cannot shadow tvshow.nfo; GetMetadata gains the same ContentType guard Search has. - New FieldReleaseDates lock gates Year/ReleaseDate/First+LastAirDate in merge (Go) and the edit-metadata dialog (web), closing the gap where a manual refresh re-applied NFO dates over admin corrections. - Merge-contract tests pin NFO fill semantics, genres whole-list first-provider-wins, and NFO edits propagating on manual refresh only. - Docs: new admin wiki page (supported fields, merge semantics, naming-supplies-structure contract), index bullet, sidecar wording revision, v1-scope feature-detection note. Zero behavior change while the provider is disabled (default); pinned by CI-mode and DB-gated test suites. Part of #216 AI-use disclosure: implemented with Claude Code (Fable 5) via spec-driven TDD and agent-assisted implementation. * feat(metadata): ingest local sidecar artwork and read series-depth NFO Phases C and D of the #216 local-NFO work, implemented test-first, plus the mixed-library use-case pins. Together these deliver the headline case: a series absent from every remote database (e.g. a fitness library) scans into a fully presented show -> named seasons -> titled episodes tree from NFO files and sidecar art alone. Local sidecar artwork through the S3 image cache (Phase C): - The NFO provider implements ImageProvider: poster/backdrop/logo sidecar discovery with a fixed precedence map, symlink/non-regular rejection, an 8 MiB cap, and file:// source URLs at rating 0. Generic filenames apply only via the sidecar search paths, so a shared folder.jpg in a flat multi-movie directory applies to none. - file:// becomes a live local source scheme: routed into *_source_path (never *_path), accepted by every image enqueue gate, attributed as provider "local", excluded from cached-path detection. - The image-cache processor caches local files with lexical-on-logical confinement to the library roots, open-handle reads with re-checks, the same variant widths as remote art, and stable (7-day) failure classification. Keys land under local/{contentType}/{contentID}/{hash8}/{imageType}; superseded prefixes are cleaned on re-cache and item deletion. - applyIfBetter gains a local exemption so rating-0 local art can fill matched items without being stickily displaced; ImageRequest carries additive sidecar path context. Series depth (Phase D): - SeasonsRequest/EpisodesRequest carry additive local path context (series roots, per-season directories, per-episode file paths), derived from naming at match time and reconstructed on refresh. - season.nfo supplies season name/plot; NFO season numbers are advisory (directory-derived number wins with a Warn - naming owns structure). <episodedetails> gains aired/runtime/ratings; <basename>.nfo titles episodes and <basename>-thumb.ext supplies thumbs; filename SxxEyy wins over NFO numbers. - Episode NFOs work without a season.nfo (provider seasons unioned with on-disk seasons); SynthesizeFallbackEpisodes always runs after persist so NFO-less episodes keep synthesized rows. Season/episode file:// art rides the Phase C pipeline unchanged. - Migration adds season:1/episode:1 to the builtin NFO capability's default_priority (still default_enabled=false). Mixed sports-library use case (tests only, no product change): - Pins the classification contract for one library holding movie-shaped and show-shaped content (WWE PPV events as movies next to a "WWE SmackDown" show, NASCAR/F1/FIFA with partial TVDB/TMDB data): naming decides movie-vs-series per file before any provider runs; the NFO supplies metadata/identity but never flips type (ContentType guard); the per-root Type override is the correction path. - NFO-driven type classification at scan time is recorded as an explicit deferred open question. Part of #216 AI-use disclosure: implemented with Claude Code (Fable 5) via spec-driven TDD and agent-assisted implementation. * docs(metadata): document local NFO metadata architecture Add a single as-built architecture page (docs/architecture/local-nfo-metadata.md) for the #216 local-NFO feature: the builtin registration model, hint-first identity semantics, the file:// -> S3 artwork pipeline and its deployment constraint, series depth, the mixed-library classification contract, and known limitations. This replaces the working implementation plan, the per-phase specs, and the narrow sidecar-artwork note, which were planning drafts and are left untracked; admin-facing behavior remains in the wiki. Part of #216 AI-use disclosure: planned, drafted, and consolidated with Claude Code (Fable 5) using multi-agent exploration and adversarial review. * fix(metadata): address PR review findings on NFO builtin provider Fold in the valid, low-risk fixes surfaced by automated review on #390: - imagecache: extract validateCacheRequest so CacheBytes (the local sidecar season/episode path) enforces the same episode-requires-season guard as Cache, preventing distinct episodes' art from colliding under one S3 key. - image_cache_processor: close the sidecar symlink-swap window by rejecting the opened handle unless os.SameFile matches the Lstat'd file, so a leaf swapped to a symlink can't pull an out-of-root target into the public cache. - plugins: guard the reserved builtin installation row in the store's Update, matching Delete, so its version/enabled/capabilities can never be rewritten even if a mutation slips past the HTTP layer. - cmd/silo: bound SyncBuiltinProviderChains with a 30s timeout so a stuck DB round-trip fails fast at startup instead of hanging. - metadata: panic instead of silently no-op'ing on an invalid RegisterBuiltinProvider call (init-time programmer error). - docs: correct the media-folder-and-naming NFO paragraph to state season/episode NFOs and sidecar artwork are actively read. --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
713 lines
26 KiB
Go
713 lines
26 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
|
|
}
|
|
// Exclude capabilities that opt out of default-enable (e.g. the seeded-
|
|
// disabled built-in NFO provider). Like the chain-less fallback, an
|
|
// installed-but-off provider must not activate itself through this
|
|
// content-level-agnostic path (person refresh); only an admin enabling its
|
|
// chain row does. Absent/unparseable metadata stays eligible (legacy).
|
|
eligible := make([]CapabilityInfo, 0, len(caps))
|
|
for _, c := range caps {
|
|
if !extractDefaultEnabled(lookupCapabilityMetadata(ctx, pool, c.PluginInstallationID, c.CapabilityID)) {
|
|
continue
|
|
}
|
|
eligible = append(eligible, c)
|
|
}
|
|
return buildProviders(ctx, eligible, 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 !providerEligibleForFallback(metadataJSON, contentLevel) {
|
|
slog.DebugContext(ctx, "skipping metadata provider: not eligible for chain-less fallback", "component", "metadata",
|
|
"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
|
|
}
|
|
|
|
// providerEligibleForFallback reports whether a capability may participate in
|
|
// the chain-less fallback for contentLevel. The fallback runs not only for
|
|
// chain-less libraries but whenever a level's enabled-filter yields zero
|
|
// entries, so a capability that opts out of being enabled by default
|
|
// (default_enabled=false — e.g. the seeded-disabled built-in NFO provider or a
|
|
// specialist plugin) must not activate itself through it. Absent or
|
|
// unparseable metadata defaults to eligible, preserving legacy behavior.
|
|
func providerEligibleForFallback(metadataJSON []byte, contentLevel string) bool {
|
|
return providerSupportsLevel(metadataJSON, contentLevel) && extractDefaultEnabled(metadataJSON)
|
|
}
|
|
|
|
// 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 {
|
|
slog.WarnContext(ctx, "skipping metadata provider: installation enabled-check failed", "component", "metadata",
|
|
"installation_id", c.PluginInstallationID, "capability_id", c.CapabilityID, "error", err)
|
|
continue
|
|
}
|
|
if !enabled {
|
|
slog.DebugContext(ctx, "skipping metadata provider: plugin installation disabled", "component", "metadata",
|
|
"installation_id", c.PluginInstallationID, "capability_id", c.CapabilityID)
|
|
continue
|
|
}
|
|
|
|
// A builtin installation's capabilities resolve to in-process providers
|
|
// from the builtin registry; everything else is a gRPC plugin provider.
|
|
if installationIsBuiltin(ctx, checker, pool, c.PluginInstallationID) {
|
|
provider, ok := builtinProvider(c.CapabilityID)
|
|
if !ok {
|
|
slog.WarnContext(ctx, "skipping builtin metadata provider: no registered constructor", "component", "metadata",
|
|
"installation_id", c.PluginInstallationID, "capability_id", c.CapabilityID)
|
|
continue
|
|
}
|
|
providers = append(providers, provider)
|
|
continue
|
|
}
|
|
|
|
provider, err := NewPluginProviderFromCapability(c.PluginInstallationID, c.CapabilityID, c.DisplayName, resolver)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "skipping metadata provider during chain resolution", "component", "metadata",
|
|
"installation_id", c.PluginInstallationID,
|
|
"capability_id", c.CapabilityID,
|
|
"error", err,
|
|
)
|
|
continue
|
|
}
|
|
providers = append(providers, provider)
|
|
}
|
|
return providers
|
|
}
|
|
|
|
// installationKindChecker is an optional extension of InstallationEnabledChecker
|
|
// that serves the installation kind from the plugins service's in-memory cache,
|
|
// letting chain resolution identify builtin rows without a per-capability DB
|
|
// query on the hot path. *plugins.Service implements it; when the checker does
|
|
// not (nil or a test fake), installationIsBuiltin falls back to a pool query.
|
|
type installationKindChecker interface {
|
|
InstallationKind(ctx context.Context, installationID int) (string, error)
|
|
}
|
|
|
|
// installationIsBuiltin reports whether a plugin installation row is the
|
|
// reserved builtin-host installation (kind='builtin'). It prefers the cached
|
|
// checker (no DB read) and falls back to a direct query. Errors (including a
|
|
// pre-migration schema without the kind column) fail closed to "plugin" so
|
|
// resolution behavior is unchanged.
|
|
func installationIsBuiltin(ctx context.Context, checker InstallationEnabledChecker, pool *pgxpool.Pool, installationID int) bool {
|
|
if kc, ok := checker.(installationKindChecker); ok {
|
|
kind, err := kc.InstallationKind(ctx, installationID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return kind == "builtin"
|
|
}
|
|
if pool == nil {
|
|
return false
|
|
}
|
|
var kind string
|
|
err := pool.QueryRow(ctx,
|
|
"SELECT kind FROM plugin_installations WHERE id = $1",
|
|
installationID,
|
|
).Scan(&kind)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return kind == "builtin"
|
|
}
|
|
|
|
// 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
|
|
}
|