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, "/") }