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
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:
1 parent
5e2d1e902b
commit
64dd8ed05e
29 files changed
+1219
-471
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user