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

This commit is contained in:
mrleo1nid committed 2026-10-22 13:52:16 +03:00
1 parent c84f90e2cc
commit e158558da3
56 files changed
+3337 -2196

No files matched your search

+13 -123
View File
@@ -3,9 +3,7 @@ package uploads
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path"
@@ -14,7 +12,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"pipu/api/internal/auth"
"pipu/api/internal/library"
@@ -30,10 +27,10 @@ type Config struct {
// Service — очередь на добавление.
type Service struct {
store *Store
lib *library.Store
libSvc *library.Service
scanner *library.Scanner
store *Store
lib SourceCatalog
*PlacementPlanner
scanner ImportedScanner
agent library.Agent
prober library.Prober
fpcalc library.FPCalc
@@ -46,10 +43,10 @@ type Service struct {
}
// NewService создаёт сервис очереди.
func NewService(store *Store, lib *library.Store, libSvc *library.Service, scanner *library.Scanner, agent library.Agent,
func NewService(store *Store, lib SourceCatalog, roots RootResolver, scanner ImportedScanner, agent library.Agent,
prober library.Prober, fpcalc library.FPCalc, cfg Config, log *slog.Logger) *Service {
return &Service{
store: store, lib: lib, libSvc: libSvc, scanner: scanner, agent: agent, prober: prober, fpcalc: fpcalc, cfg: cfg,
store: store, lib: lib, PlacementPlanner: &PlacementPlanner{store: store, lib: lib, roots: roots}, scanner: scanner, agent: agent, prober: prober, fpcalc: fpcalc, cfg: cfg,
log: log, wakeProcess: make(chan struct{}, 1), wakeImport: make(chan struct{}, 1),
now: func() time.Time { return time.Now().UTC() },
}
@@ -233,7 +230,7 @@ func cleanClientPath(p *string) *string {
// Complete — tus принял файл целиком: в обработку.
func (s *Service) Complete(ctx context.Context, f File) error {
err := s.store.Transition(ctx, f.ID, []Status{StatusUploading}, StatusProcessing, nil)
err := s.store.ChangeStatus(ctx, f.ID, []Status{StatusUploading}, StatusProcessing)
if err == nil {
wake(s.wakeProcess)
}
@@ -279,7 +276,7 @@ func (s *Service) Withdraw(ctx context.Context, id uuid.UUID) error {
if err != nil || f.AuthorID != p.UserID {
return apperr.NotFound(CodeNotFound, "upload not found")
}
if err := s.store.Transition(ctx, id, []Status{StatusUploading, StatusProcessing, StatusPendingReview}, StatusWithdrawn, nil); err != nil {
if err := s.store.ChangeStatus(ctx, id, []Status{StatusUploading, StatusProcessing, StatusPendingReview}, StatusWithdrawn); err != nil {
return notFound(err)
}
s.removeStaging(f)
@@ -324,14 +321,8 @@ func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpdateInput) (Fil
target = in.TargetSourceID
}
pl := s.plan(ctx, f, m, target)
raw, _ := json.Marshal(m)
_, err = s.store.pool.Exec(ctx, `
UPDATE upload_files SET metadata = $2, metadata_edited = $3, target_source_id = $4, target_rel_path = $5,
status_reason = $6, matched_artist_id = $7, matched_album_id = $8, updated_at = now()
WHERE id = $1 AND status IN ('pending_review', 'import_failed')`,
id, raw, edited, pl.SourceID, pl.RelPath, nullable(pl.Reason), pl.ArtistID, pl.AlbumID)
if err != nil {
return File{}, fmt.Errorf("uploads: update: %w", err)
if err := s.store.UpdateMetadata(ctx, id, m, edited, pl); err != nil {
return File{}, notFound(err)
}
return s.store.File(ctx, id)
}
@@ -385,13 +376,8 @@ func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve
if err != nil {
return notFound(err)
}
mark := func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE upload_files SET reviewed_by = $2, reviewed_at = now(), status_reason = $3 WHERE id = $1`,
id, reviewer, nullable(reason))
return err
}
if !approve {
if err := s.store.Transition(ctx, id, []Status{StatusPendingReview, StatusImportFailed}, StatusRejected, mark); err != nil {
if err := s.store.Decide(ctx, id, reviewer, false, reason); err != nil {
return notFound(err)
}
s.removeStaging(f)
@@ -406,7 +392,7 @@ func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve
return apperr.Conflict(pl.Reason, "cannot place the file into a storage")
}
reason = ""
if err := s.store.Transition(ctx, id, []Status{StatusPendingReview}, StatusApproved, mark); err != nil {
if err := s.store.Decide(ctx, id, reviewer, true, reason); err != nil {
return notFound(err)
}
s.store.Audit(ctx, &reviewer, "upload.approve", id, map[string]any{"target": pl.RelPath})
@@ -418,7 +404,7 @@ func (s *Service) Retry(ctx context.Context, id uuid.UUID) (File, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return File{}, err
}
if err := s.store.Transition(ctx, id, []Status{StatusImportFailed}, StatusApproved, nil); err != nil {
if err := s.store.ChangeStatus(ctx, id, []Status{StatusImportFailed}, StatusApproved); err != nil {
return File{}, notFound(err)
}
wake(s.wakeImport)
@@ -495,102 +481,6 @@ func (s *Service) UpdateSettings(ctx context.Context, in SettingsInput) (Setting
return st, nil
}
// --- Раскладка в хранилище ---
// placement — куда ляжет файл; Reason — почему не получится (код), путь тогда nil.
type placement struct {
SourceID *uuid.UUID
RelPath *string
Reason string
ArtistID *uuid.UUID
AlbumID *uuid.UUID
src library.Source
root string
}
// pickSource — хранилище: выбранное админом → по умолчанию → единственное принимающее загрузки.
func (s *Service) 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 *Service) 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.libSvc.Root(src)
if err != nil {
pl.Reason = CodeNoTarget
return pl
}
rel = reuseExisting(root, rel)
pl.RelPath, pl.root = &rel, root
return pl
}
// extFor — расширение по реальному формату (кодек из ffprobe), иначе — по имени файла.
func extFor(f File) string {
orig := strings.TrimPrefix(strings.ToLower(path.Ext(f.OriginalName)), ".")