Files
pipu-music/services/api/internal/uploads/placement.go
T
mrleo1nid 30c3c85f89
ci / api / api (push) Successful in 50s
ci / db / db (push) Successful in 56s
ci / module boundaries / boundaries (push) Successful in 8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
ci / ts / ts (packages + web) (push) Failing after 4m28s
fix(uploads): refresh destination previews and improve queue layout
2026-10-29 11:10:54 +03:00

169 lines
5.0 KiB
Go

package uploads
import (
"context"
"errors"
"path/filepath"
"strings"
"github.com/google/uuid"
"pipu/api/internal/library"
"pipu/api/internal/library/meta"
)
// Ports are owned by the upload use cases; implementations remain in library.
type SourceCatalog interface {
GetSource(context.Context, uuid.UUID) (library.Source, error)
ListSources(context.Context) ([]library.Source, error)
Patterns(context.Context, uuid.UUID) ([]library.PatternRow, error)
}
type RootResolver interface {
Root(library.Source) (string, error)
}
type ImportedScanner interface {
ScanFile(context.Context, library.Source, string, string) (uuid.UUID, error)
}
type PlacementStore interface {
Settings(context.Context) (Settings, error)
MatchCatalog(context.Context, string, string) (*uuid.UUID, *uuid.UUID, int, error)
BatchMaxDisc(context.Context, uuid.UUID, string) (int, error)
}
type PlacementPlanner struct {
store PlacementStore
lib SourceCatalog
roots RootResolver
}
// --- Раскладка в хранилище ---
// placement — куда ляжет файл; Reason — почему не получится (код), путь тогда nil.
type placement struct {
SourceID *uuid.UUID
RelPath *string
Reason string
ArtistID *uuid.UUID
AlbumID *uuid.UUID
src library.Source
root string
}
// previewPlacement uses the same current configuration as importFile. Finalized
// files keep their recorded destination; reading the queue never changes a decision.
func (s *PlacementPlanner) previewPlacement(ctx context.Context, f *File) {
f.TargetStoragePath = nil
if f.Meta != nil && (f.Status == StatusPendingReview || f.Status == StatusImportFailed || f.Status == StatusApproved) {
pl := s.plan(ctx, *f, *f.Meta, f.TargetSourceID)
f.TargetSourceID, f.TargetRelPath = pl.SourceID, pl.RelPath
f.TargetSourceName = nil
if pl.SourceID != nil {
f.TargetSourceName = &pl.src.Name
}
if f.Status == StatusPendingReview || pl.Reason != "" {
f.StatusReason = nullable(pl.Reason)
}
f.MatchedArtistID, f.MatchedAlbumID = pl.ArtistID, pl.AlbumID
}
if f.TargetSourceID == nil || f.TargetRelPath == nil {
return
}
src, err := s.lib.GetSource(ctx, *f.TargetSourceID)
if err != nil {
return
}
f.TargetSourceName = &src.Name
if src.Type == library.SourceNFS {
if src.NFSServer != nil && src.NFSExport != nil {
full := *src.NFSServer + ":" + strings.TrimRight(*src.NFSExport, "/") + "/" + *f.TargetRelPath
f.TargetStoragePath = &full
}
} else if root, err := s.roots.Root(src); err == nil {
full := filepath.Join(root, filepath.FromSlash(*f.TargetRelPath))
f.TargetStoragePath = &full
}
}
// pickSource — хранилище: выбранное админом → по умолчанию → единственное принимающее загрузки.
func (s *PlacementPlanner) pickSource(ctx context.Context, chosen *uuid.UUID) (library.Source, string) {
id := chosen
if id == nil {
st, err := s.store.Settings(ctx)
if err == nil {
id = st.DefaultSourceID
}
}
if id != nil {
src, err := s.lib.GetSource(ctx, *id)
if err != nil || !src.AcceptsUploads {
return library.Source{}, CodeNoTarget
}
return src, ""
}
sources, err := s.lib.ListSources(ctx)
if err != nil {
return library.Source{}, CodeNoTarget
}
var found []library.Source
for _, src := range sources {
if src.AcceptsUploads {
found = append(found, src)
}
}
if len(found) != 1 {
return library.Source{}, CodeNoTarget
}
return found[0], ""
}
// plan считает целевой путь по write_template хранилища; существующие папки исполнителя / альбома
// переиспользуются в их написании (docs/uploads.md#очистка-имён).
func (s *PlacementPlanner) plan(ctx context.Context, f File, m Meta, chosen *uuid.UUID) placement {
var (
pl placement
discs int // дисков в альбоме: в каталоге, в пачке, у самого файла
)
if artist, album, maxDisc, err := s.store.MatchCatalog(ctx, meta.Key(m.AlbumArtist), meta.Key(m.Album)); err == nil {
pl.ArtistID, pl.AlbumID, discs = artist, album, maxDisc
}
src, reason := s.pickSource(ctx, chosen)
if reason != "" {
pl.Reason = reason
return pl
}
pl.SourceID, pl.src = &src.ID, src
if src.WriteTemplate == nil {
pl.Reason = CodeTemplate
return pl
}
tpl, err := meta.ParseTemplate(*src.WriteTemplate)
if err != nil {
pl.Reason = CodeTemplate
return pl
}
if n, err := s.store.BatchMaxDisc(ctx, f.BatchID, m.Album); err == nil {
discs = max(discs, n)
}
if m.Disc != nil {
discs = max(discs, *m.Disc)
}
rel, err := tpl.Render(meta.TemplateInput{Fields: m.Fields(), DiscCount: discs, Ext: extFor(f)})
if err != nil {
var mf *meta.MissingFieldError
if errors.As(err, &mf) {
pl.Reason = CodeMissingField
} else {
pl.Reason = CodeTemplate
}
return pl
}
root, err := s.roots.Root(src)
if err != nil {
pl.Reason = CodeNoTarget
return pl
}
rel = reuseExisting(root, rel)
pl.RelPath, pl.root = &rel, root
return pl
}