package library import ( "context" "errors" "fmt" "math/rand/v2" "net/http" "path/filepath" "slices" "sync" "github.com/google/uuid" "pipu/api/internal/auth" "pipu/api/internal/library/meta" "pipu/api/internal/platform/apperr" ) type PatternStore interface { GetSource(context.Context, uuid.UUID) (Source, error) Patterns(context.Context, uuid.UUID) ([]PatternRow, error) ReplacePatterns(context.Context, uuid.UUID, []PatternRow) error SampleFiles(context.Context, uuid.UUID, int) ([]string, error) TemplateSamples(context.Context, uuid.UUID, int) ([]meta.TemplateSample, error) } // TemplateService owns metadata pattern preview and write template validation. type TemplateService struct { store PatternStore prober Prober root func(Source) (string, error) } // Patterns — шаблоны путей источника. func (s *TemplateService) Patterns(ctx context.Context, id uuid.UUID) ([]PatternRow, error) { if err := requirePerm(ctx, auth.PermManageLibrary); err != nil { return nil, err } if _, err := s.store.GetSource(ctx, id); err != nil { return nil, notFound(err) } return s.store.Patterns(ctx, id) } func compile(patterns []PatternRow) ([]meta.Pattern, error) { var exprs []string idx := []int{} for i, p := range patterns { if p.IsEnabled { exprs = append(exprs, p.Regex) idx = append(idx, i) } } compiled, err := meta.CompilePatterns(exprs) var pe *meta.PatternError if errors.As(err, &pe) { at := idx[pe.Index] return nil, &apperr.Error{ Status: http.StatusUnprocessableEntity, Code: CodePatternInvalid, Detail: pe.Reason, Params: map[string]any{"index": at, "reason": pe.Reason}, Fields: []apperr.FieldError{{Field: fmt.Sprintf("patterns[%d].regex", at), Code: CodePatternInvalid}}, } } return compiled, err } // PutPatterns проверяет и сохраняет шаблоны. func (s *TemplateService) PutPatterns(ctx context.Context, id uuid.UUID, patterns []PatternRow) ([]PatternRow, error) { if err := requirePerm(ctx, auth.PermManageLibrary); err != nil { return nil, err } if _, err := s.store.GetSource(ctx, id); err != nil { return nil, notFound(err) } if _, err := compile(patterns); err != nil { return nil, err } if err := s.store.ReplacePatterns(ctx, id, patterns); err != nil { return nil, err } return s.store.Patterns(ctx, id) } // PreviewItem — разбор одного файла. type PreviewItem struct { RelPath string PatternIndex *int Skipped bool PathFields map[string]string Tags map[string]string Result meta.Meta Error string } // Preview применяет шаблоны к выборке файлов без сохранения: из каталога, а до первого скана — из обхода. func (s *TemplateService) Preview(ctx context.Context, id uuid.UUID, patterns []PatternRow, sample int) ([]PreviewItem, int, error) { if err := requirePerm(ctx, auth.PermManageLibrary); err != nil { return nil, 0, err } src, err := s.store.GetSource(ctx, id) if err != nil { return nil, 0, notFound(err) } compiled, err := compile(patterns) if err != nil { return nil, 0, err } sample = min(max(sample, 1), 200) root, err := s.root(src) if err != nil { return nil, 0, err } paths, err := s.store.SampleFiles(ctx, id, sample) if err != nil { return nil, 0, err } if len(paths) == 0 { found, err := walk(root, src.ExcludedDirs, func(string) bool { return false }, nil) if err != nil { return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error()) } rand.Shuffle(len(found), func(i, j int) { found[i], found[j] = found[j], found[i] }) for _, w := range found[:min(sample, len(found))] { paths = append(paths, w.rel) } } slices.Sort(paths) items := make([]PreviewItem, len(paths)) var wg sync.WaitGroup sem := make(chan struct{}, 8) for i, rel := range paths { wg.Add(1) go func() { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() items[i] = s.previewOne(ctx, root, rel, compiled, src.TagsFirst) }() } wg.Wait() unmatched := 0 for _, it := range items { if it.PatternIndex == nil { unmatched++ } } return items, unmatched, nil } func (s *TemplateService) previewOne(ctx context.Context, root, rel string, patterns []meta.Pattern, tagsFirst bool) PreviewItem { it := PreviewItem{RelPath: rel, PathFields: map[string]string{}, Tags: map[string]string{}} m := meta.MatchPath(patterns, rel) if m.Index >= 0 { idx := m.Index it.PatternIndex = &idx } it.Skipped = m.Skip for k, v := range m.Fields { it.PathFields[k] = v } p, err := s.prober.Probe(ctx, filepath.Join(root, filepath.FromSlash(rel))) if err != nil { it.Error = err.Error() } for k, v := range map[string]string{ "artist": p.Tags.Artist, "albumartist": p.Tags.AlbumArtist, "album": p.Tags.Album, "title": p.Tags.Title, "genre": p.Tags.Genre, "year": p.Tags.Year, "track": p.Tags.Track, "disc": p.Tags.Disc, } { if v != "" { it.Tags[k] = v } } it.Result = meta.Merge(p.Tags, meta.FromPath(m.Fields), tagsFirst, rel) if it.Result.Album == "" { it.Result.Album = UnknownAlbum } return it } // templateSampleSize — сколько треков берёт проверка шаблона записи (docs/uploads.md#7). const templateSampleSize = 50 // SuggestWriteTemplate подбирает шаблон записи по существующим путям и выражениям источника. func (s *TemplateService) SuggestWriteTemplate(ctx context.Context, id uuid.UUID) (string, error) { if err := requirePerm(ctx, auth.PermManageLibrary); err != nil { return "", err } if _, err := s.store.GetSource(ctx, id); err != nil { return "", notFound(err) } rows, err := s.store.Patterns(ctx, id) if err != nil { return "", err } var exprs []string for _, r := range rows { if r.IsEnabled { exprs = append(exprs, r.Regex) } } paths, err := s.store.SampleFiles(ctx, id, 200) if err != nil { return "", err } tpl, err := meta.SuggestTemplate(exprs, paths) if err != nil { return "", apperr.New(http.StatusUnprocessableEntity, CodeTemplateNone, "no path pattern can be turned into a template") } return tpl, nil } // CheckWriteTemplate применяет шаблон к трекам источника и разбирает пути его выражениями обратно. func (s *TemplateService) CheckWriteTemplate(ctx context.Context, id uuid.UUID, template string) (meta.TemplateCheck, error) { if err := requirePerm(ctx, auth.PermManageLibrary); err != nil { return meta.TemplateCheck{}, err } if _, err := s.store.GetSource(ctx, id); err != nil { return meta.TemplateCheck{}, notFound(err) } tpl, err := meta.ParseTemplate(template) if err != nil { return meta.TemplateCheck{}, apperr.Validation("template", CodeTemplateInvalid, err.Error(), map[string]any{"reason": err.Error()}) } rows, err := s.store.Patterns(ctx, id) if err != nil { return meta.TemplateCheck{}, err } compiled, err := compile(rows) if err != nil { return meta.TemplateCheck{}, err } samples, err := s.store.TemplateSamples(ctx, id, templateSampleSize) if err != nil { return meta.TemplateCheck{}, err } return meta.CheckTemplate(tpl, compiled, samples), nil }