Files
pipu-music/services/api/internal/library/meta/template_suggest.go
T
mrleo1nid 64dd8ed05e
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Failing after 4m4s
ci / api / api (push) Successful in 55s
ci / db / db (push) Failing after 1m14s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
feat(uploads): preview storage paths, detect templates, attach covers and withdraw batches
2026-10-29 10:28:29 +03:00

130 lines
3.5 KiB
Go

package meta
import (
"path"
"sort"
"strconv"
"strings"
"golang.org/x/text/unicode/norm"
)
// TemplateSuggestion compares candidate layouts against the same sample of stored paths.
type TemplateSuggestion struct {
Template string
Matched int
Total int
Examples []string
}
// SuggestFromSamples also works without configured path regexes, using catalog metadata.
func SuggestFromSamples(exprs []string, samples []TemplateSample) (TemplateSuggestion, error) {
candidates := map[string]bool{}
paths := make([]string, 0, len(samples))
for _, s := range samples {
paths = append(paths, s.RelPath)
if tpl := inferTemplate(s); tpl != "" {
candidates[tpl] = true
}
}
for _, expr := range exprs {
if tpl, err := SuggestTemplate([]string{expr}, paths); err == nil {
candidates[tpl] = true
}
}
keys := make([]string, 0, len(candidates))
for tpl := range candidates {
keys = append(keys, tpl)
}
sort.Strings(keys)
best := TemplateSuggestion{Total: len(samples), Examples: []string{}}
for _, tpl := range keys {
t, err := ParseTemplate(tpl)
if err != nil {
continue
}
count := 0
var examples []string
for _, s := range samples {
if rendered, err := t.Render(s.Input); err == nil && rendered == norm.NFC.String(s.RelPath) {
count++
if len(examples) < 3 {
examples = append(examples, s.RelPath)
}
}
}
if count > best.Matched {
best.Template, best.Matched, best.Examples = tpl, count, examples
}
}
if best.Matched == 0 {
return best, ErrNoTemplate
}
return best, nil
}
func inferTemplate(s TemplateSample) string {
type token struct{ value, placeholder string }
segments := strings.Split(norm.NFC.String(s.RelPath), "/")
for i, segment := range segments {
last := i == len(segments)-1
fields := []string{"albumartist", "artist", "album", "year", "genre", "disc"}
if last {
ext := path.Ext(segment)
if ext == "" {
return ""
}
segment = strings.TrimSuffix(segment, ext)
fields = []string{"title", "albumartist", "artist", "album", "year", "track", "disc"}
}
var tokens []token
for _, field := range fields {
v := cleanValue(fieldValue(s.Input, tnode{field: field}))
if v == "" {
continue
}
tokens = append(tokens, token{v, "{" + field + "}"})
if field == "track" || field == "disc" {
n, _ := strconv.Atoi(v)
for width := 2; width <= 3; width++ {
padded := strings.Repeat("0", max(0, width-len(v))) + strconv.Itoa(n)
if padded != v {
tokens = append(tokens, token{padded, "{" + field + ":0" + strconv.Itoa(width) + "}"})
}
}
}
}
// Longest value wins; stable ties prefer album artist in folders and title in filenames.
sort.SliceStable(tokens, func(a, b int) bool { return len(tokens[a].value) > len(tokens[b].value) })
var out strings.Builder
for at := 0; at < len(segment); {
matched := false
for _, tok := range tokens {
if !strings.HasPrefix(segment[at:], tok.value) {
continue
}
end := at + len(tok.value)
if tok.value[0] >= '0' && tok.value[0] <= '9' && ((at > 0 && segment[at-1] >= '0' && segment[at-1] <= '9') || (end < len(segment) && segment[end] >= '0' && segment[end] <= '9')) {
continue
}
out.WriteString(tok.placeholder)
at = end
matched = true
break
}
if !matched {
if strings.ContainsRune("{}[]", rune(segment[at])) {
return ""
}
out.WriteByte(segment[at])
at++
}
}
segments[i] = out.String()
if last {
segments[i] += ".{ext}"
}
}
return strings.Join(segments, "/")
}