* perf(jellycompat,sections): bound resume scan, batch leaf detail progress, widen section concurrency Three low-risk fixes from the section-fetch performance investigation (docs/superpowers/plans/2026-07-03-section-fetch-performance.md): - jellycompat: bound loadProgressPage at resumeScanMaxRows=300 so a single request never pages through more than that many in-progress rows. The cap is unconditional: it also covers the sparse-visible-set case (a heavy watcher whose recent rows are mostly dismissed/superseded, or a Series/Season-only request that matches no leaf in-progress row), where the page never fills and the loop would otherwise scan the entire history — previously an O(history) scan reaching tens of seconds. In the common case the loop exits far earlier, so the cap only bounds the pathological worst case; 300 leaves ample headroom to fill a ~20-item Continue Watching page. Beyond the cap the reported total is a clamped lower bound. Covered by TestLoadProgressPage_BoundsScanForSparseVisibleSet. - jellycompat: batch the leaf-item (movie/episode) progress lookup in GetItemDetailsByIDs via ListProgressWithCompletedHistory instead of a per-item GetProgressWithCompletedHistory (~100 sequential queries for a 50-item detail page). Series keep the per-item episode-rollup path (they own no progress row). Output is unchanged; a batch-lookup failure is now logged rather than silently dropping played state for the whole page. - sections: raise fetchAllMaxConcurrency 4 -> 6 to cut FetchAll wave count for large home layouts, staying within the default 20-conn pool. Part of the home/continue-watching latency work. AI-use disclosure: implemented with AI (Claude) assistance. * perf(jellycompat): keep Latest browse on the cross-library fast path under isPlayed /Items/Latest with isPlayed=false is the highest-frequency compat browse (~10.8k calls/day). The played overlay can't be pushed into SQL, so browse over-fetches and filters locally. The cross-library recently_added fast path (BrowseRecentlyAddedAcrossLibraries: one ~1ms index walk per library) was gated on Offset==0, so a heavy watcher who had already seen the newest items needed a 2nd chunk and fell through to BrowsePage — a whole-catalog MIN(first_seen_at) + GROUP BY HashAggregate over ~147k movies measured at ~755ms per call (0.8-1.6s observed end-to-end). Fetch the entire over-fetch budget (maxScannedRows) in a single merged fast-path walk instead of paging into BrowsePage, so the loop fills from one call. The clamp caveat (MaxLimit=1000 leaves a fall-through only for requestedLimit>200, off the Latest hot path) is documented inline. Part of the home/browse latency work. AI-use disclosure: implemented with AI (Claude) assistance. * fix(jellycompat): scope resume scan cap to resume path and bound the fast-path loop Addresses PR #292 review feedback: - Codex (P2): the resumeScanMaxRows cap was applied unconditionally in the general loop, which also paginates the completed (watched-items) list. Gate it on resumeFiltered so the completed path keeps exact TotalRecordCount and deep StartIndex pagination. Covered by TestLoadProgressPage_CompletedScanNotCapped. - CodeRabbit (Critical): the earlier raw-offset fast-path loop — the default Continue Watching shape and the sections-fallback route — had the same unbounded-scan bug and was not covered by the cap (the existing test forces EnableTotalRecordCount=true, routing around it). Bound it with the same resumeScanMaxRows guard. Covered by TestLoadProgressPage_BoundsFastPathScanForSparseVisibleSet. - CodeRabbit (Minor): tag the doc's fenced example blocks as text to satisfy markdownlint MD040. AI-use disclosure: implemented with AI (Claude) assistance. * perf(sections): cache shared user-agnostic home rails per access scope Home-screen rails that are identical for everyone who can see the same libraries (recently added, recently released, genre, trending on server, most watched, new to library, critically acclaimed, award winners, format showcase, seasonal, mood, trending discover, admin-curated lists, and library collections) were rebuilt from Postgres once per request, per user. Only the overlay on top of each row (watched flags, play position, presigned poster URLs) is actually per-user. Insert a process-global resolved-list cache at the FetchOne choke point in internal/sections. Each cacheable row is built once per access scope, held with a 15m TTL, and refreshed in the background 3m before expiry; singleflight collapses cold-miss stampedes into a single build. The per-user overlay still runs fresh in buildSectionsResponse, so no profile state is ever shared. Random and per-user rows (continue watching, next up, recommendations, hidden gems, forgotten favorites, activity feed, user collections) bypass the cache. The access-scope key captures every access boundary the fetch path enforces -- section identity (type + id + config hash) + item limit + accessible and disabled libraries + max content rating + excluded media types + name prefix + allowed-content-id allowlist -- and nothing per-user, so entries are safely shared. Empty membership is never cached (avoids freezing a transiently empty rail); background refreshes are bounded by a timeout. Scale (analytical, derived from the cache behavior -- not a measured latency): for the user-agnostic rows, Postgres section-query volume collapses from O(rows x concurrent requests) to O(rows x distinct access scopes) per 15m refresh window, because most users share a handful of access scopes. Illustrative -- 40 cacheable rows on a home screen, 1000 concurrent users falling into ~5 distinct access scopes: - before: ~40 x 1000 = ~40,000 section queries per wave of home loads - after: ~40 x 5 = ~200 builds per 15m window (plus one background refresh per row per scope), i.e. a warm home load runs zero section queries for these rows. That is a ~99% reduction in shared section-query load at that concurrency; the win grows with concurrency and shrinks as access-scope diversity rises. Design/plan doc added under docs/superpowers/plans/. * perf(jellycompat): serve per-library Latest via the cached recently-added section A jellyfin-compat per-library /Items/Latest rail is the same user-agnostic list as the native "recently added" library rail -- both order by mil.first_seen_at DESC. It was rebuilt on every request through directContentService.BrowseItems, missing the resolved-list cache entirely. Route per-library Latest for movies and series libraries through the native section fetch instead, so it reuses the shared cache. HandleLatest resolves the library's type once, and for a movies/series library builds a synthetic SectionRecentlyAdded with the same type + config + limit + access scope the native rail uses and calls FetchOne; the per-user overlay (favorites, progress, episode targets, presign) is extracted into buildLatestItemDTOs and shared by both the native and BrowseItems paths, so no overlay logic is duplicated. Cached *models.MediaItem values are read-only -- LocalizeItemModels deep-copies before any presign mutation. To let the two surfaces share one entry, resolvedListCacheKey no longer includes the arbitrary section ID: every cacheable section type derives its membership from type + config + limit + scope, never from its own ID (audited all 14 cacheable types plus the library-collection path; the sole s.ID read lives in the non-cacheable user-collection branch). A native recently-added rail and the compat Latest for the same library + scope now collapse to ONE cache entry, built once and reused. Access-scope isolation is unchanged -- the removed ID never carried access information, and every access boundary (libraries, rating cap, excluded types, content allow-list, name prefix) still keys the entry. Guardrails: the native path is restricted to movies and series libraries; every other library type (ebook, music, manga, mixed) is ignored and keeps its exact BrowseItems behavior -- important because an unfiltered recently-added fetch would otherwise surface non-video items to Jellyfin clients that only expect video. Deeper pages, played-filter and backdrop-required requests, a client asking for a type other than the library's own, and any FetchOne error also fall back to BrowseItems. Chosen over an alternative that gave the synthetic section a deterministic ID (which kept two separate cache entries): both returned identical data with similar complexity, so the shared-entry design won. * fix(sections,jellycompat): post-review fixes for the shared-list cache and Latest path Consolidates fixes from the branch's adversarial review and PR #292 review comments into one commit: - Latest fast path: fall back to BrowseItems when a request carries a genre, name-prefix, or person filter (the synthetic recently-added section cannot express these, so serving it unfiltered would return a wrong, broader set). Eligibility is decided by latestFastPathEligible and covered by a test. - Clamp the /Items/Latest page size to compatBrowseMaxLimit before building the section, matching the BrowseItems fallback, so a large client Limit can't drive an oversized recently-added fetch or explode the shared cache key with unbounded ItemLimit values. - Evict expired entries from the process-global resolvedListCache: resolvedListSet sweeps expired keys at most once per minute, bounding the map to scopes seen within one TTL window. Covered by TestResolvedListCacheEvictsExpiredEntries. - Log a short digest of the cache key (resolvedListLogKey) instead of the raw key in the background-refresh panic/error paths, since the key embeds user-controlled access-scope fields such as NamePrefix. Skipped review comments (verified already fixed or stale against current code): the resume fast-path scan bound and watched-items cap (04d2e795) and the docs fence-language tags (already addressed). Build, vet, and go test -race pass for internal/sections and internal/jellycompat. * perf(plugins): cache plugin installations in-memory, invalidated on lifecycle change ## Problem Every poster/image on a warm home rail re-read plugin_installations from Postgres to answer "is this plugin enabled?" and to acquire the plugin client (Source A: metadata chain buildProviders enabled-check; Source B: ensureClient -> loadInstallation). Plugin-resolved image URLs are never URL-cached, so the plugin source and the DB read behind it fired again on every identical warm request; 100% of images in the target library are plugin-backed. ## Solution - Guarded in-memory installation cache (map[int]*Installation + RWMutex) in plugins.Service. loadInstallation reads through it; the requireEnabled gate stays after the cache read so ErrInstallationDisabled semantics are unchanged. invalidateInstallationCache clears it and is self-registered as a lifecycle hook, so Service.OnLifecycleChange wipes it on install/enable/disable/update/ uninstall. - A generation counter closes an invalidate-vs-repopulate race: captured before installations.GetByID and re-checked under the write lock, so a row fetched before a lifecycle mutation is never written into a freshly cleared cache (would otherwise resurrect a just-disabled plugin). - Route the metadata chain enabled-check through the same cache via a structural InstallationEnabledChecker interface (nil-safe: falls back to the pool query when no checker is injected), wired in cmd/silo/main.go. ## Post-review fix (auto-update reliability blocker) AutoUpdateService mutated installations (new InstallPath, old dir deleted) on the default auto update policy without firing OnLifecycleChange, leaving the cache stale and breaking plugins with "stored plugin manifest mismatch" until restart. It now takes an onChange callback wired to Service.OnLifecycleChange and fires it once per Check run that mutated a row. ## Verification go build/vet, go test ./internal/plugins/... ./internal/metadata/... (-race). Tests: cache hit/invalidation, racing-invalidation guard, IsInstallationEnabled, auto-update fires onChange. ## AI-use disclosure Implemented with AI assistance (Claude). * perf(jellycompat): batch per-item presign, and enrich series on the cached Latest path ## Problem List rails presigned each item's poster/backdrop/logo/still image individually (~160 singular resolver calls for a 40-item page where 4 batched calls suffice), and ItemsHandler carried a near-verbatim duplicate of the batch presigner. ## Solution (batching) Promote the batch presigner to a shared package-level presignCompatListItems (presign_list.go) with a generic collectImagePaths[T]; convert the per-item loops (cached home/Latest rail, favorites, batch loaders, userdata favorites) to one batched PresignImageURLsWithExpiry per image type per page; batch the season/episode collections; delete the three duplicate presign helpers. URL output is unchanged (verified byte-for-byte). ## Post-review fix (series Latest data-parity regression) The native cached Latest fast path built items via compatListItemsFromModels + buildLatestItemDTOs and never ran the series watch-state rollup, so a series library's Latest lost Played / UnplayedItemCount and page 1 disagreed with the BrowseItems fallback. enrichSeriesUserData is promoted to the ContentService interface and called on the native path (reused, not duplicated). ## Verification go build/vet, go test ./internal/jellycompat/... ./internal/catalog/... Tests: bounded presign invocation counts + per-item URL mapping; series rollup populated on the native Latest path. ## AI-use disclosure Implemented with AI assistance (Claude). * perf(sections): gate personalized rails out of the shared cache; widen refresh lead ## Problem 1. The shared home-rail cache whitelisted custom_filter/genre sections by TYPE alone, but those route through fetchFiltered -> ParseQueryDefinition and can carry personalized (per-profile) rules/sorts (watched, favorited, in_watchlist, in_progress, last_watched; sorts progress/date_viewed/plays). Their membership is per-profile yet the cache key excludes userID/profileID, so a personalized rail built for one profile was served to others in the same access scope for up to 15m -- a cross-profile watchlist/watch-state leak. 2. The background-refresh lead was tuned so steady traffic is served a warm entry from a longer soft window. ## Solution - Add QueryDefinition.IsPersonalized() (reusing the existing QueryFieldRequiresProfile/QuerySortRequiresProfile helpers). isCacheableSectionType now parses the section QueryDefinition and refuses to cache custom_filter/genre when personalized; non-personalized definitions stay cacheable. Seasonal/mood/trending build their definitions server-side and stay unconditionally cacheable. - resolvedListRefreshLead 3m -> 10m (soft threshold builtAt+5min instead of builtAt+12min). ## Verification go build/vet, go test ./internal/sections/... ./internal/catalog/... (-race). Test: personalized custom_filter/genre not cacheable; non-personalized are. ## AI-use disclosure Implemented with AI assistance (Claude). * fix(sections,metadata): post-review fixes for shared cache and plugin chain staleness Addresses three review findings on PR #292: - sections: canonicalize section config JSON before hashing so configs differing only in whitespace/field order share a cache entry (native + jellycompat rail sharing). Added TestHashSectionConfigCanonicalizes. - metadata: invalidate the resolved-chain cache on plugin lifecycle changes; the installation-enabled check already reads the invalidated plugin cache, but resolveChainCached could serve a stale provider chain for up to chainCacheTTL after a provider's availability changed. - jellycompat: move ctx to the first parameter of presignCompatListItems for consistency with the other presign helpers. Skipped the episode-image presign batching nitpick: the resolver already dedupes+singleflights, so it is a Minor perf-only item not worth the two-pass refactor risk in this pass. * fix(sections,jellycompat): harden shared rail cache and Latest fast path per review Addresses the eight findings from the deep review of this PR: - Detach the blocking cold-miss rebuild from the singleflight leader's request context (context.WithoutCancel + the shared 30s build timeout) so one client disconnect no longer fails every collapsed waiter and leaves the entry uncached. - Stop client-controlled values minting unbounded cache entries: the compat Latest fast path now always fetches a fixed 100-row budget and slices to the requested limit (one entry per scope+library instead of one per Limit value), and an unrecognized MaxOfficialRating string disqualifies the fast path instead of entering the global cache key. - Add release_date to the sections item projection/scan so movies served via the Latest fast path keep PremiereDate (Jellyfin default-set field) in parity with the BrowseItems fallback. - Fall back to per-item progress lookups when the batched leaf progress query fails, restoring one-item-at-a-time degradation instead of blanking played state for the whole page. - Derive cache eligibility from a single source of truth: fetchSection and isCacheableSectionType now share the userAgnosticSectionFetcher table, whose no-userID/profileID signature makes a fetcher drop out of the cacheable set at compile time if it ever gains per-profile inputs. - Decide Latest fast-path eligibility off the actual browse params the fallback would receive, so any filter later added to buildBrowseParams automatically disqualifies the cached path; share one compatDefaultBrowseLimit constant between both paths. - Extract AccessFilter.WriteAccessScopeCacheKey as the shared, security- critical serializer for all access-scoped caches (resolved-list, editorial candidates, audiobook groups); the editorial key now captures ExcludedMediaTypes, which its loaders already applied in SQL. - Strip leaked agent-transcript markup from the section-fetch plan doc. go build ./..., go vet, gofmt clean; go test -race on internal/sections, internal/catalog, internal/jellycompat passes (TestBeginWebOperation* failures are the known pre-existing flakes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
568 lines
19 KiB
Go
568 lines
19 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 doesn't already include it. The defaultPriority
|
|
// callback returns the plugin's declared priority for a content level (0 means
|
|
// the plugin doesn't declare that level — the entry is still added but disabled).
|
|
func (r *ChainRepository) AppendProviderToAllChains(
|
|
ctx context.Context,
|
|
pluginInstallationID int,
|
|
capabilityID string,
|
|
defaultPriority func(contentLevel string) int,
|
|
) 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 {
|
|
// 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)
|
|
}
|
|
|
|
dp := defaultPriority(g.contentLevel)
|
|
enabled := dp > 0
|
|
|
|
_, 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
|
|
}
|
|
|
|
// 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
|
|
}
|