Files
silo-server/internal/metadata/plugin_provider.go
T
383973ec22 feat(metadata): improve match accuracy and localized titles (#461)
* feat(metadata): improve match accuracy and localized titles

* fix(metadata): address matching review findings

* test(catalog): align empty alias snapshot scope

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-24 12:02:52 -04:00

716 lines
22 KiB
Go

package metadata
import (
"context"
"fmt"
"strconv"
"strings"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/pluginhost"
)
type pluginMetadataResolver interface {
MetadataProviderClient(ctx context.Context, installationID int, capabilityID string) (pluginMetadataClient, error)
}
type pluginMetadataClient interface {
Search(ctx context.Context, req *pluginv1.SearchMetadataRequest) (*pluginv1.SearchMetadataResponse, error)
GetMetadata(ctx context.Context, req *pluginv1.GetMetadataRequest) (*pluginv1.GetMetadataResponse, error)
GetPersonDetail(ctx context.Context, req *pluginv1.GetPersonDetailRequest) (*pluginv1.GetPersonDetailResponse, error)
GetSeasons(ctx context.Context, req *pluginv1.GetSeasonsRequest) (*pluginv1.GetSeasonsResponse, error)
GetEpisodes(ctx context.Context, req *pluginv1.GetEpisodesRequest) (*pluginv1.GetEpisodesResponse, error)
GetImages(ctx context.Context, req *pluginv1.GetImagesRequest) (*pluginv1.GetImagesResponse, error)
ResolveImageURL(ctx context.Context, req *pluginv1.ResolveImageURLRequest) (*pluginv1.ResolveImageURLResponse, error)
ResolveImageURLs(ctx context.Context, req *pluginv1.ResolveImageURLsRequest) (*pluginv1.ResolveImageURLsResponse, error)
}
type pluginMetadataClientFactory func(ctx context.Context, installationID int, capabilityID string) (pluginMetadataClient, error)
// PluginResolverAdapter wraps a concrete resolver (like *plugins.Service)
// whose MetadataProviderClient returns *pluginhost.MetadataProviderClient,
// adapting it to satisfy the pluginMetadataResolver interface.
type PluginResolverAdapter struct {
inner interface {
MetadataProviderClient(ctx context.Context, installationID int, capabilityID string) (*pluginhost.MetadataProviderClient, error)
}
}
// NewPluginResolverAdapter creates an adapter from a concrete plugin service.
func NewPluginResolverAdapter(svc interface {
MetadataProviderClient(ctx context.Context, installationID int, capabilityID string) (*pluginhost.MetadataProviderClient, error)
}) *PluginResolverAdapter {
if svc == nil {
return nil
}
return &PluginResolverAdapter{inner: svc}
}
func (a *PluginResolverAdapter) MetadataProviderClient(ctx context.Context, installationID int, capabilityID string) (pluginMetadataClient, error) {
return a.inner.MetadataProviderClient(ctx, installationID, capabilityID)
}
type PluginProvider struct {
installationID int
capabilityID string
displayName string
clientFactory pluginMetadataClientFactory
}
func NewPluginProvider(settings map[string]string, resolver pluginMetadataResolver) (*PluginProvider, error) {
if resolver == nil {
return nil, fmt.Errorf("plugin metadata resolver is required")
}
return newPluginProvider(settings, resolver.MetadataProviderClient)
}
func newPluginProvider(
settings map[string]string,
clientFactory pluginMetadataClientFactory,
) (*PluginProvider, error) {
if clientFactory == nil {
return nil, fmt.Errorf("plugin metadata client factory is required")
}
installationIDText := settings["plugin_installation_id"]
if installationIDText == "" {
return nil, fmt.Errorf("plugin_installation_id is required")
}
installationID, err := strconv.Atoi(installationIDText)
if err != nil {
return nil, fmt.Errorf("parse plugin_installation_id %q: %w", installationIDText, err)
}
capabilityID := settings["capability_id"]
if capabilityID == "" {
return nil, fmt.Errorf("capability_id is required")
}
displayName := settings["display_name"]
if displayName == "" {
displayName = capabilityID
}
return &PluginProvider{
installationID: installationID,
capabilityID: capabilityID,
displayName: displayName,
clientFactory: clientFactory,
}, nil
}
func NewPluginProviderWithClientFactory(
settings map[string]string,
clientFactory pluginMetadataClientFactory,
) (*PluginProvider, error) {
return newPluginProvider(settings, clientFactory)
}
// NewPluginProviderFromCapability constructs a PluginProvider directly from
// plugin capability data, without going through a settings map or registry.
func NewPluginProviderFromCapability(
installationID int,
capabilityID string,
displayName string,
resolver pluginMetadataResolver,
) (*PluginProvider, error) {
if resolver == nil {
return nil, fmt.Errorf("plugin metadata resolver is required")
}
if displayName == "" {
displayName = capabilityID
}
return &PluginProvider{
installationID: installationID,
capabilityID: capabilityID,
displayName: displayName,
clientFactory: resolver.MetadataProviderClient,
}, nil
}
func NewPluginProviderWithTypedResolver(
settings map[string]string,
resolver interface {
MetadataProviderClient(
ctx context.Context,
installationID int,
capabilityID string,
) (*pluginhost.MetadataProviderClient, error)
},
) (*PluginProvider, error) {
if resolver == nil {
return nil, fmt.Errorf("plugin metadata resolver is required")
}
return newPluginProvider(settings, func(
ctx context.Context,
installationID int,
capabilityID string,
) (pluginMetadataClient, error) {
return resolver.MetadataProviderClient(ctx, installationID, capabilityID)
})
}
func (p *PluginProvider) Slug() string {
return p.capabilityID
}
func (p *PluginProvider) Name() string {
return p.displayName
}
func (p *PluginProvider) ForTypes() []string {
return []string{"movie", "series"}
}
func (p *PluginProvider) Search(ctx context.Context, query SearchQuery) ([]SearchResult, error) {
client, err := p.clientFactory(ctx, p.installationID, p.capabilityID)
if err != nil {
return nil, err
}
providerIDs, err := structFromStringMap(query.ProviderIDs)
if err != nil {
return nil, fmt.Errorf("encode provider ids for plugin search: %w", err)
}
// The plugin search contract carries a single free-text Query. When the
// caller supplies an author hint (ebooks), fold it in so title-only matches
// that need disambiguation — or messy filename titles — can still resolve.
queryText := strings.TrimSpace(query.Title)
if author := strings.TrimSpace(query.Author); author != "" {
queryText = strings.TrimSpace(queryText + " " + author)
}
response, err := client.Search(ctx, &pluginv1.SearchMetadataRequest{
Query: queryText,
ItemType: query.ContentType,
Year: int32(query.Year),
ProviderIds: providerIDs,
Language: query.Language,
})
if err != nil {
return nil, err
}
results := make([]SearchResult, 0, len(response.GetResults()))
for _, result := range response.GetResults() {
results = append(results, SearchResult{
Name: result.GetTitle(),
OriginalTitle: result.GetOriginalTitle(),
TitleAliases: titleAliasesFromPlugin(result.GetTitleAliases(), p.Slug()),
TitleLanguage: result.GetTitleLanguage(),
TitleIsFallback: result.GetTitleIsFallback(),
OriginalLanguage: result.GetOriginalLanguage(),
Year: int(result.GetYear()),
ProviderIDs: mergePluginProviderIDs(p.capabilityID, result.GetProviderId(), result.GetProviderIds()),
ImageURL: result.GetImageUrl(),
Overview: result.GetOverview(),
Provider: p.Slug(),
})
}
return results, nil
}
func (p *PluginProvider) GetMetadata(ctx context.Context, req MetadataRequest) (*MetadataResult, error) {
providerID := req.ProviderIDs[p.capabilityID]
if providerID == "" {
return nil, nil
}
client, err := p.clientFactory(ctx, p.installationID, p.capabilityID)
if err != nil {
return nil, err
}
providerIDs, err := structFromStringMap(req.ProviderIDs)
if err != nil {
return nil, err
}
response, err := client.GetMetadata(ctx, &pluginv1.GetMetadataRequest{
ProviderId: providerID,
ItemType: req.ContentType,
ProviderIds: providerIDs,
Language: req.Language,
FilePath: req.FilePath,
})
if err != nil {
return nil, err
}
if response.GetItem() == nil {
return nil, nil
}
return &MetadataResult{
HasMetadata: true,
ProviderIDs: mergePluginProviderIDs(p.capabilityID, response.GetItem().GetProviderId(), response.GetItem().GetProviderIds()),
Title: response.GetItem().GetTitle(),
OriginalTitle: response.GetItem().GetOriginalTitle(),
TitleAliases: titleAliasesFromPlugin(response.GetItem().GetTitleAliases(), p.Slug()),
TitleAliasesComplete: response.GetItem().GetTitleAliasesComplete(),
TitleLanguage: response.GetItem().GetTitleLanguage(),
TitleIsFallback: response.GetItem().GetTitleIsFallback(),
SortTitle: response.GetItem().GetSortTitle(),
Overview: response.GetItem().GetOverview(),
Tagline: response.GetItem().GetTagline(),
Year: int(response.GetItem().GetYear()),
Runtime: int(response.GetItem().GetRuntime()),
Genres: append([]string(nil), response.GetItem().GetGenres()...),
Keywords: keywordsFromPluginMetadata(response.GetItem().GetMetadata()),
Studios: append([]string(nil), response.GetItem().GetStudios()...),
Networks: append([]string(nil), response.GetItem().GetNetworks()...),
Countries: append([]string(nil), response.GetItem().GetCountries()...),
OriginalLanguage: response.GetItem().GetOriginalLanguage(),
ContentRating: response.GetItem().GetContentRating(),
Ratings: ratingsFromStruct(response.GetItem().GetRatings()),
People: peopleFromRecords(response.GetItem().GetPeople()),
Videos: videosFromRecords(p.Slug(), response.GetItem().GetVideos()),
PosterPath: response.GetItem().GetPosterPath(),
PosterThumbhash: response.GetItem().GetPosterThumbhash(),
BackdropPath: response.GetItem().GetBackdropPath(),
ShowStatus: response.GetItem().GetStatus(),
BackdropThumbhash: response.GetItem().GetBackdropThumbhash(),
LogoPath: response.GetItem().GetLogoPath(),
SeasonCount: int(response.GetItem().GetSeasonCount()),
FirstAirDate: response.GetItem().GetFirstAirDate(),
LastAirDate: response.GetItem().GetLastAirDate(),
AirTime: response.GetItem().GetAirTime(),
ReleaseDate: response.GetItem().GetReleaseDate(),
}, nil
}
func titleAliasesFromPlugin(aliases []*pluginv1.TitleAlias, provider string) []TitleAlias {
out := make([]TitleAlias, 0, len(aliases))
for _, alias := range aliases {
if alias == nil || strings.TrimSpace(alias.GetTitle()) == "" {
continue
}
out = append(out, TitleAlias{
Title: alias.GetTitle(), Language: alias.GetLanguage(), Kind: alias.GetKind(), Provider: provider,
})
}
return out
}
func (p *PluginProvider) GetPersonDetail(ctx context.Context, req PersonDetailRequest) (*PersonDetailResult, error) {
client, err := p.clientFactory(ctx, p.installationID, p.capabilityID)
if err != nil {
return nil, err
}
providerIDs, err := structFromStringMap(req.ProviderIDs)
if err != nil {
return nil, err
}
response, err := client.GetPersonDetail(ctx, &pluginv1.GetPersonDetailRequest{
ProviderIds: providerIDs,
Language: req.Language,
})
if err != nil {
if status.Code(err) == codes.Unimplemented {
return nil, nil
}
return nil, err
}
if response.GetPerson() == nil {
return nil, nil
}
return &PersonDetailResult{
Name: response.GetPerson().GetName(),
SortName: response.GetPerson().GetSortName(),
Bio: response.GetPerson().GetBio(),
BirthDate: response.GetPerson().GetBirthDate(),
DeathDate: response.GetPerson().GetDeathDate(),
Birthplace: response.GetPerson().GetBirthplace(),
Homepage: response.GetPerson().GetHomepage(),
PhotoPath: response.GetPerson().GetPhotoPath(),
PhotoThumbhash: response.GetPerson().GetPhotoThumbhash(),
ProviderIDs: stringMapFromStruct(response.GetPerson().GetProviderIds()),
}, nil
}
func (p *PluginProvider) GetImages(ctx context.Context, req ImageRequest) ([]RemoteImage, error) {
providerID := req.ProviderIDs[p.capabilityID]
if providerID == "" {
return nil, nil
}
client, err := p.clientFactory(ctx, p.installationID, p.capabilityID)
if err != nil {
return nil, err
}
providerIDs, err := structFromStringMap(req.ProviderIDs)
if err != nil {
return nil, err
}
response, err := client.GetImages(ctx, &pluginv1.GetImagesRequest{
ProviderId: providerID,
ItemType: req.ContentType,
ProviderIds: providerIDs,
Language: req.Language,
})
if err != nil {
return nil, err
}
images := make([]RemoteImage, 0, len(response.GetImages()))
for _, image := range response.GetImages() {
ri := RemoteImage{
ProviderID: p.capabilityID,
URL: image.GetUrl(),
Type: imageTypeFromKind(image.GetKind()),
Language: image.GetLanguage(),
Width: int(image.GetWidth()),
Height: int(image.GetHeight()),
}
// Extract rating from the metadata struct if the plugin provided it.
if md := image.GetMetadata(); md != nil {
if v, ok := md.GetFields()["rating"]; ok {
ri.Rating = v.GetNumberValue()
}
}
images = append(images, ri)
}
return images, nil
}
func (p *PluginProvider) GetSeasons(ctx context.Context, req SeasonsRequest) ([]SeasonResult, error) {
providerID := req.ProviderIDs[p.capabilityID]
if providerID == "" {
return nil, nil
}
client, err := p.clientFactory(ctx, p.installationID, p.capabilityID)
if err != nil {
return nil, err
}
providerIDs, err := structFromStringMap(req.ProviderIDs)
if err != nil {
return nil, err
}
response, err := client.GetSeasons(ctx, &pluginv1.GetSeasonsRequest{
SeriesProviderId: providerID,
ProviderIds: providerIDs,
Language: req.Language,
})
if err != nil {
return nil, err
}
seasons := make([]SeasonResult, 0, len(response.GetSeasons()))
for _, season := range response.GetSeasons() {
ids := mergePluginProviderIDs(p.capabilityID, season.GetProviderId(), season.GetProviderIds())
seasons = append(seasons, SeasonResult{
ContentID: ids[p.capabilityID],
SeasonNumber: int(season.GetSeasonNumber()),
Title: season.GetTitle(),
Overview: season.GetOverview(),
AirDate: season.GetAirDate(),
PosterPath: season.GetPosterPath(),
})
}
return seasons, nil
}
func (p *PluginProvider) GetEpisodes(ctx context.Context, req EpisodesRequest) ([]EpisodeResult, error) {
providerID := req.ProviderIDs[p.capabilityID]
if providerID == "" {
return nil, nil
}
client, err := p.clientFactory(ctx, p.installationID, p.capabilityID)
if err != nil {
return nil, err
}
providerIDs, err := structFromStringMap(req.ProviderIDs)
if err != nil {
return nil, err
}
response, err := client.GetEpisodes(ctx, &pluginv1.GetEpisodesRequest{
SeriesProviderId: providerID,
SeasonNumber: int32(req.SeasonNumber),
ProviderIds: providerIDs,
Language: req.Language,
})
if err != nil {
return nil, err
}
episodes := make([]EpisodeResult, 0, len(response.GetEpisodes()))
for _, episode := range response.GetEpisodes() {
ids := mergePluginProviderIDs(p.capabilityID, episode.GetProviderId(), episode.GetProviderIds())
episodes = append(episodes, EpisodeResult{
ContentID: ids[p.capabilityID],
ProviderIDs: ids,
SeasonNumber: int(episode.GetSeasonNumber()),
EpisodeNumber: int(episode.GetEpisodeNumber()),
Title: episode.GetTitle(),
Overview: episode.GetOverview(),
AirDate: episode.GetAirDate(),
Runtime: int(episode.GetRuntime()),
Ratings: ratingsFromStruct(episode.GetRatings()),
StillPath: episode.GetStillPath(),
})
}
return episodes, nil
}
// ResolveImageURL resolves a single image path via the plugin's gRPC call.
// The path should be a bare path without the plugin prefix.
func (p *PluginProvider) ResolveImageURL(ctx context.Context, path string, variant string) (string, error) {
client, err := p.clientFactory(ctx, p.installationID, p.capabilityID)
if err != nil {
return "", err
}
response, err := client.ResolveImageURL(ctx, &pluginv1.ResolveImageURLRequest{
Path: path, Variant: variant,
})
if err != nil {
return "", err
}
return response.GetUrl(), nil
}
// ResolveImageURLs resolves multiple image paths via a single plugin gRPC call.
// Paths should be bare paths without the plugin prefix.
func (p *PluginProvider) ResolveImageURLs(ctx context.Context, paths []string, variant string) (map[string]string, error) {
client, err := p.clientFactory(ctx, p.installationID, p.capabilityID)
if err != nil {
return nil, err
}
response, err := client.ResolveImageURLs(ctx, &pluginv1.ResolveImageURLsRequest{
Paths: paths, Variant: variant,
})
if err != nil {
return nil, err
}
return response.GetUrls(), nil
}
func mergePluginProviderIDs(capabilityID, providerID string, ids *structpb.Struct) map[string]string {
merged := stringMapFromStruct(ids)
if providerID != "" {
merged[capabilityID] = providerID
}
return merged
}
func stringMapFromStruct(value *structpb.Struct) map[string]string {
result := make(map[string]string)
if value == nil {
return result
}
for key, raw := range value.AsMap() {
text, ok := raw.(string)
if ok && text != "" {
result[key] = text
}
}
return result
}
func ratingsFromStruct(value *structpb.Struct) Ratings {
var ratings Ratings
if value == nil {
return ratings
}
for key, raw := range value.AsMap() {
number, ok := raw.(float64)
if !ok {
continue
}
switch key {
case "imdb":
ratings.IMDB = number
case "tmdb":
ratings.TMDB = number
case "rt_critic":
ratings.RTCritic = number
case "rt_audience":
ratings.RTAudience = number
}
}
return ratings
}
func keywordsFromPluginMetadata(value *structpb.Struct) []string {
if value == nil {
return nil
}
raw, ok := value.AsMap()["keywords"]
if !ok {
return nil
}
var entries []any
switch typed := raw.(type) {
case []any:
entries = typed
case []string:
entries = make([]any, 0, len(typed))
for _, entry := range typed {
entries = append(entries, entry)
}
default:
return nil
}
keywords := make([]string, 0, len(entries))
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
text, ok := entry.(string)
if !ok {
continue
}
text = strings.TrimSpace(text)
if text == "" {
continue
}
key := strings.ToLower(text)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
keywords = append(keywords, text)
}
return keywords
}
func structFromStringMap(value map[string]string) (*structpb.Struct, error) {
if len(value) == 0 {
return nil, nil
}
converted := make(map[string]any, len(value))
for key, entry := range value {
if entry == "" {
continue
}
converted[key] = entry
}
if len(converted) == 0 {
return nil, nil
}
return structpb.NewStruct(converted)
}
// videosFromRecords maps SDK VideoRecords into domain RemoteVideos, stamping
// the returning provider's slug and normalizing unknown kinds to "other".
// Plugins built against an SDK without the videos field simply return an
// empty list (proto3 zero value) — nil here, no error.
func videosFromRecords(providerSlug string, records []*pluginv1.VideoRecord) []RemoteVideo {
if len(records) == 0 {
return nil
}
videos := make([]RemoteVideo, 0, len(records))
for _, record := range records {
if record == nil || record.GetSiteKey() == "" {
continue
}
// The provider key is the DB dedup key; fall back to the site key so a
// plugin omitting provider ids cannot collide on the empty string.
providerKey := record.GetProviderKey()
if providerKey == "" {
providerKey = record.GetSiteKey()
}
videos = append(videos, RemoteVideo{
Provider: providerSlug,
ProviderKey: providerKey,
Kind: models.NormalizeExtraKind(record.GetKind()),
Site: strings.ToLower(record.GetSite()),
SiteKey: record.GetSiteKey(),
Name: record.GetName(),
Language: record.GetLanguage(),
IsOfficial: record.GetIsOfficial(),
SizeHint: int(record.GetSizeHint()),
PublishedAt: record.GetPublishedAt(),
})
}
return videos
}
func peopleFromRecords(records []*pluginv1.PersonRecord) []models.ItemPerson {
if len(records) == 0 {
return nil
}
people := make([]models.ItemPerson, 0, len(records))
for _, record := range records {
if record == nil {
continue
}
people = append(people, models.ItemPerson{
Person: models.Person{
Name: record.GetName(),
TmdbID: record.GetTmdbId(),
TvdbID: record.GetTvdbId(),
ImdbID: record.GetImdbId(),
PlexGUID: record.GetPlexGuid(),
PhotoPath: record.GetPhotoPath(),
PhotoThumbhash: record.GetPhotoThumbhash(),
},
Kind: personKindFromString(record.GetKind()),
Character: record.GetCharacter(),
SortOrder: int(record.GetSortOrder()),
})
}
return people
}
func personKindFromString(value string) models.PersonKind {
switch strings.ToLower(strings.TrimSpace(value)) {
case "actor":
return models.PersonKindActor
case "director":
return models.PersonKindDirector
case "writer":
return models.PersonKindWriter
case "producer":
return models.PersonKindProducer
case "gueststar", "guest_star", "guest star":
return models.PersonKindGuestStar
case "composer":
return models.PersonKindComposer
case "author":
return models.PersonKindAuthor
case "narrator":
return models.PersonKindNarrator
default:
return models.PersonKindFromJob(value)
}
}
func imageTypeFromKind(kind string) ImageType {
switch kind {
case "backdrop":
return ImageBackdrop
case "logo":
return ImageLogo
case "still":
return ImageStill
case "profile":
return ImageProfile
default:
return ImagePoster
}
}