Deluan Quintão fda35dd8ce
feat(plugins): add similar songs retrieval functions and improve duration consistency (#4933)
* feat: add duration filtering for similar songs matching

Signed-off-by: Deluan <deluan@navidrome.org>

* test: refactor expectations for similar songs in provider matching tests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(plugins): add functions to retrieve similar songs by track, album, and artist

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(plugins): support uint32 in ndpgen

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(plugins): update duration field to use seconds as float instead of milliseconds as uint32

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: add helper functions for Rust's skip_serializing_if with numeric types

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(provider): enhance track matching logic to fallback to title match when duration-filtered tracks fail

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-26 18:28:41 -05:00

334 lines
9.0 KiB
Go

package internal
import (
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// XTP Schema types for YAML marshalling
type (
xtpSchema struct {
Version string `yaml:"version"`
Exports yaml.Node `yaml:"exports,omitempty"`
Components *xtpComponents `yaml:"components,omitempty"`
}
xtpComponents struct {
Schemas yaml.Node `yaml:"schemas"`
}
xtpExport struct {
Description string `yaml:"description,omitempty"`
Input *xtpIOParam `yaml:"input,omitempty"`
Output *xtpIOParam `yaml:"output,omitempty"`
}
xtpIOParam struct {
Ref string `yaml:"$ref,omitempty"`
Type string `yaml:"type,omitempty"`
ContentType string `yaml:"contentType"`
}
// xtpObjectSchema represents an object schema in XTP.
// Per the XTP JSON Schema, ObjectSchema has properties, required, and description
// but NOT a type field.
xtpObjectSchema struct {
Description string `yaml:"description,omitempty"`
Properties yaml.Node `yaml:"properties"`
Required []string `yaml:"required,omitempty"`
}
xtpEnumSchema struct {
Description string `yaml:"description,omitempty"`
Type string `yaml:"type"`
Enum []string `yaml:"enum"`
}
xtpProperty struct {
Ref string `yaml:"$ref,omitempty"`
Type string `yaml:"type,omitempty"`
Format string `yaml:"format,omitempty"`
Description string `yaml:"description,omitempty"`
Nullable bool `yaml:"nullable,omitempty"`
Items *xtpProperty `yaml:"items,omitempty"`
}
)
// GenerateSchema generates an XTP YAML schema from a capability.
func GenerateSchema(cap Capability) ([]byte, error) {
schema := xtpSchema{Version: "v1-draft"}
// Build exports as ordered map
if len(cap.Methods) > 0 {
schema.Exports = yaml.Node{Kind: yaml.MappingNode}
for _, export := range cap.Methods {
addToMap(&schema.Exports, export.ExportName, buildExport(export))
}
}
// Build components/schemas
schemas := buildSchemas(cap)
if len(schemas.Content) > 0 {
schema.Components = &xtpComponents{Schemas: schemas}
}
return yaml.Marshal(schema)
}
func buildExport(export Export) xtpExport {
e := xtpExport{Description: cleanDocForYAML(export.Doc)}
if export.Input.Type != "" {
e.Input = &xtpIOParam{
Ref: "#/components/schemas/" + strings.TrimPrefix(export.Input.Type, "*"),
ContentType: "application/json",
}
}
if export.Output.Type != "" {
outputType := strings.TrimPrefix(export.Output.Type, "*")
// Check if output is a primitive type
if isPrimitiveGoType(outputType) {
e.Output = &xtpIOParam{
Type: goTypeToXTPType(outputType),
ContentType: "application/json",
}
} else {
e.Output = &xtpIOParam{
Ref: "#/components/schemas/" + outputType,
ContentType: "application/json",
}
}
}
return e
}
// isPrimitiveGoType returns true if the Go type is a primitive type.
func isPrimitiveGoType(goType string) bool {
switch goType {
case "bool", "string", "int", "int32", "int64", "uint", "uint32", "uint64", "float32", "float64", "[]byte":
return true
}
return false
}
func buildSchemas(cap Capability) yaml.Node {
schemas := yaml.Node{Kind: yaml.MappingNode}
knownTypes := cap.KnownStructs()
for _, alias := range cap.TypeAliases {
knownTypes[alias.Name] = true
}
// Collect types that are actually used by exports
usedTypes := collectUsedTypes(cap, knownTypes)
// Sort structs by name for consistent output
structNames := make([]string, 0, len(cap.Structs))
structMap := make(map[string]StructDef)
for _, st := range cap.Structs {
if usedTypes[st.Name] {
structNames = append(structNames, st.Name)
structMap[st.Name] = st
}
}
sort.Strings(structNames)
for _, name := range structNames {
st := structMap[name]
addToMap(&schemas, name, buildObjectSchema(st, knownTypes))
}
// Build enum types from type aliases (only if used by exports)
for _, alias := range cap.TypeAliases {
if !usedTypes[alias.Name] {
continue
}
if alias.Type == "string" {
for _, cg := range cap.Consts {
if cg.Type == alias.Name {
addToMap(&schemas, alias.Name, buildEnumSchema(alias, cg))
break
}
}
}
}
return schemas
}
// collectUsedTypes returns a set of type names that are reachable from exports.
func collectUsedTypes(cap Capability, knownTypes map[string]bool) map[string]bool {
used := make(map[string]bool)
// Start with types directly referenced by exports
for _, export := range cap.Methods {
if export.Input.Type != "" {
addTypeAndDeps(strings.TrimPrefix(export.Input.Type, "*"), cap, knownTypes, used)
}
if export.Output.Type != "" {
outputType := strings.TrimPrefix(export.Output.Type, "*")
if !isPrimitiveGoType(outputType) {
addTypeAndDeps(outputType, cap, knownTypes, used)
}
}
}
return used
}
// addTypeAndDeps adds a type and all its dependencies to the used set.
func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, used map[string]bool) {
if used[typeName] || !knownTypes[typeName] {
return
}
used[typeName] = true
// Find the struct and add its field types
for _, st := range cap.Structs {
if st.Name == typeName {
for _, field := range st.Fields {
fieldType := strings.TrimPrefix(field.Type, "*")
fieldType = strings.TrimPrefix(fieldType, "[]")
if knownTypes[fieldType] {
addTypeAndDeps(fieldType, cap, knownTypes, used)
}
}
return
}
}
}
func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema {
schema := xtpObjectSchema{
Description: cleanDocForYAML(st.Doc),
Properties: yaml.Node{Kind: yaml.MappingNode},
}
for _, field := range st.Fields {
propName := getJSONFieldName(field)
addToMap(&schema.Properties, propName, buildProperty(field, knownTypes))
if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty {
schema.Required = append(schema.Required, propName)
}
}
return schema
}
func buildEnumSchema(alias TypeAlias, cg ConstGroup) xtpEnumSchema {
values := make([]string, 0, len(cg.Values))
for _, cv := range cg.Values {
values = append(values, strings.Trim(cv.Value, `"`))
}
return xtpEnumSchema{
Description: cleanDocForYAML(alias.Doc),
Type: "string",
Enum: values,
}
}
func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
goType := field.Type
isPointer := strings.HasPrefix(goType, "*")
if isPointer {
goType = goType[1:]
}
prop := xtpProperty{
Description: cleanDocForYAML(field.Doc),
Nullable: isPointer,
}
// Handle reference types (use $ref instead of type)
if isKnownType(goType, knownTypes) && !strings.HasPrefix(goType, "[]") {
prop.Ref = "#/components/schemas/" + goType
return prop
}
// Handle slice types
if strings.HasPrefix(goType, "[]") {
elemType := goType[2:]
prop.Type = "array"
prop.Items = &xtpProperty{}
if isKnownType(elemType, knownTypes) {
prop.Items.Ref = "#/components/schemas/" + elemType
} else {
prop.Items.Type = goTypeToXTPType(elemType)
}
return prop
}
// Handle primitive types
prop.Type, prop.Format = goTypeToXTPTypeAndFormat(goType)
return prop
}
// addToMap adds a key-value pair to a yaml.Node map, preserving insertion order.
func addToMap[T any](node *yaml.Node, key string, value T) {
var valNode yaml.Node
_ = valNode.Encode(value)
node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, &valNode)
}
func getJSONFieldName(field FieldDef) string {
propName := field.JSONTag
if idx := strings.Index(propName, ","); idx >= 0 {
propName = propName[:idx]
}
if propName == "" {
propName = field.Name
}
return propName
}
// isKnownType checks if a type is a known struct or type alias.
func isKnownType(typeName string, knownTypes map[string]bool) bool {
return knownTypes[typeName]
}
// goTypeToXTPType converts a Go type to an XTP schema type.
func goTypeToXTPType(goType string) string {
typ, _ := goTypeToXTPTypeAndFormat(goType)
return typ
}
// goTypeToXTPTypeAndFormat converts a Go type to XTP type and format.
func goTypeToXTPTypeAndFormat(goType string) (typ, format string) {
switch goType {
case "string":
return "string", ""
case "int", "int32":
return "integer", "int32"
case "int64":
return "integer", "int64"
case "uint", "uint32":
// XTP schema doesn't support unsigned formats; use int64 to hold full uint32 range
return "integer", "int64"
case "uint64":
// XTP schema doesn't support unsigned formats; use int64 (may lose precision for large values)
return "integer", "int64"
case "float32":
return "number", "float"
case "float64":
return "number", "float"
case "bool":
return "boolean", ""
case "[]byte":
return "string", "byte"
default:
return "object", ""
}
}
// cleanDocForYAML cleans documentation for YAML output.
func cleanDocForYAML(doc string) string {
doc = strings.TrimSpace(doc)
// Remove leading "// " from each line if present
lines := strings.Split(doc, "\n")
for i, line := range lines {
lines[i] = strings.TrimPrefix(strings.TrimSpace(line), "// ")
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}