Files
pipu-music/services/api/internal/uploads/placement.go
T
mrleo1nidandClaude Sonnet 5.5 5d0decbb2a
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 33s
ci / db / db (push) Successful in 1m47s
Use the primary artist for upload destination folders
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 23:14:48 +03:00

172 lines
5.1 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 при ненулевом пути — предупреждение.
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.PrimaryArtist()), 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.RenderWithoutMissingYear(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
if m.Year == nil || *m.Year <= 0 {
pl.Reason = CodeMissingYear
}
return pl
}