ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 1m8s
ci / recommender / recommender (push) Failing after 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / ts / ts (packages + web) (push) Successful in 7m20s
240 lines
7.1 KiB
Go
240 lines
7.1 KiB
Go
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
|
|
}
|