mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
* 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>
334 lines
9.0 KiB
Go
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"))
|
|
}
|