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.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.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 }