refactor: isolate playback and upload use cases
ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
This commit is contained in:
1 parent
c84f90e2cc
commit
e158558da3
56 files changed
+3337
-2196
No files matched your search
@@ -0,0 +1,239 @@
|
||||
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, func(string) bool { return false })
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user