172 lines
5.1 KiB
Go
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
|
|
}
|