feat(uploads): preview storage paths, detect templates, attach covers and withdraw batches
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

This commit is contained in:
mrleo1nid committed 2026-10-29 10:28:29 +03:00
1 parent 5e2d1e902b
commit 64dd8ed05e
29 files changed
+1219 -471

No files matched your search

+1 -1
View File
@@ -184,7 +184,7 @@ func (h *Handler) SuggestWriteTemplate(ctx context.Context, req gen.SuggestWrite
if err != nil {
return nil, err
}
return gen.SuggestWriteTemplate200JSONResponse{Template: tpl}, nil
return gen.SuggestWriteTemplate200JSONResponse{Template: tpl.Template, Matched: &tpl.Matched, Total: &tpl.Total, Examples: &tpl.Examples}, nil
}
// CheckWriteTemplate реализует gen.StrictServerInterface.
@@ -0,0 +1,129 @@
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, "/")
}
@@ -0,0 +1,22 @@
package meta
import "testing"
func TestSuggestFromSamplesUsesDominantLayoutWithoutRegexes(t *testing.T) {
samples := []TemplateSample{
{RelPath: "Кино/Группа крови (1988)/01. Группа крови.flac", Input: in(Fields{Artist: "Кино", Album: "Группа крови", Title: "Группа крови", Year: "1988", Track: "1"}, 1, "flac")},
{RelPath: "Кино/Группа крови (1988)/02. Закрой за мной дверь.flac", Input: in(Fields{Artist: "Кино", Album: "Группа крови", Title: "Закрой за мной дверь", Year: "1988", Track: "2"}, 1, "flac")},
{RelPath: "Queen/Jazz (1978)/03. Jealousy.mp3", Input: in(Fields{Artist: "Queen", Album: "Jazz", Title: "Jealousy", Year: "1978", Track: "3"}, 1, "mp3")},
{RelPath: "Other - Song.flac", Input: in(Fields{Artist: "Other", Album: "Single", Title: "Song"}, 1, "flac")},
}
got, err := SuggestFromSamples(nil, samples)
if err != nil || got.Template != "{albumartist}/{album} ({year})/{track:02}. {title}.{ext}" || got.Matched != 3 || got.Total != 4 || len(got.Examples) != 3 {
t.Fatalf("suggestion = %+v, err = %v", got, err)
}
}
func TestSuggestFromSamplesDoesNotGuessWithoutMetadata(t *testing.T) {
if _, err := SuggestFromSamples(nil, []TemplateSample{{RelPath: "unknown/file.flac"}}); err == nil {
t.Fatal("cannot infer a safe layout without matching metadata")
}
}
+8 -8
View File
@@ -183,16 +183,16 @@ func (s *TemplateService) previewOne(ctx context.Context, root, rel string, patt
const templateSampleSize = 50
// SuggestWriteTemplate подбирает шаблон записи по существующим путям и выражениям источника.
func (s *TemplateService) SuggestWriteTemplate(ctx context.Context, id uuid.UUID) (string, error) {
func (s *TemplateService) SuggestWriteTemplate(ctx context.Context, id uuid.UUID) (meta.TemplateSuggestion, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return "", err
return meta.TemplateSuggestion{}, err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return "", notFound(err)
return meta.TemplateSuggestion{}, notFound(err)
}
rows, err := s.store.Patterns(ctx, id)
if err != nil {
return "", err
return meta.TemplateSuggestion{}, err
}
var exprs []string
for _, r := range rows {
@@ -200,13 +200,13 @@ func (s *TemplateService) SuggestWriteTemplate(ctx context.Context, id uuid.UUID
exprs = append(exprs, r.Regex)
}
}
paths, err := s.store.SampleFiles(ctx, id, 200)
samples, err := s.store.TemplateSamples(ctx, id, 200)
if err != nil {
return "", err
return meta.TemplateSuggestion{}, err
}
tpl, err := meta.SuggestTemplate(exprs, paths)
tpl, err := meta.SuggestFromSamples(exprs, samples)
if err != nil {
return "", apperr.New(http.StatusUnprocessableEntity, CodeTemplateNone, "no path pattern can be turned into a template")
return meta.TemplateSuggestion{}, apperr.New(http.StatusUnprocessableEntity, CodeTemplateNone, "no layout could be inferred from catalog paths and metadata")
}
return tpl, nil
}