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
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:
1 parent
c84f90e2cc
commit
e158558da3
56 files changed
+3337
-2196
No files matched your search
@@ -0,0 +1,99 @@
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ChangeStatus deliberately does not expose pgx to use cases.
|
||||
func (st *Store) ChangeStatus(ctx context.Context, id uuid.UUID, from []Status, to Status) error {
|
||||
return st.Transition(ctx, id, from, to, nil)
|
||||
}
|
||||
|
||||
// UpdateMetadata uses one guarded UPDATE; a concurrent decision is reported to the caller.
|
||||
func (st *Store) UpdateMetadata(ctx context.Context, id uuid.UUID, m Meta, edited bool, pl placement) error {
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := st.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 fmt.Errorf("uploads: update: %w", err)
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return ErrWrongStatus
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decide keeps quota release, status and reviewer metadata in the same transaction.
|
||||
func (st *Store) Decide(ctx context.Context, id, reviewer uuid.UUID, approve bool, reason string) error {
|
||||
from, to := []Status{StatusPendingReview, StatusImportFailed}, StatusRejected
|
||||
if approve {
|
||||
from, to, reason = []Status{StatusPendingReview}, StatusApproved, ""
|
||||
}
|
||||
return st.Transition(ctx, id, from, to, 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
|
||||
})
|
||||
}
|
||||
|
||||
func (st *Store) FailImport(ctx context.Context, id uuid.UUID, reason string) error {
|
||||
return st.Transition(ctx, id, []Status{StatusImporting}, StatusImportFailed, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `UPDATE upload_files SET status_reason=$2 WHERE id=$1`, id, reason)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (st *Store) CompleteImport(ctx context.Context, id, sourceID uuid.UUID, rel string, mediaFileID uuid.UUID) error {
|
||||
return st.Transition(ctx, id, []Status{StatusImporting}, StatusImported, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `UPDATE upload_files SET target_source_id=$2, target_rel_path=$3 WHERE id=$1`, id, sourceID, rel); err != nil {
|
||||
return err
|
||||
}
|
||||
return MarkImported(ctx, tx, id, mediaFileID)
|
||||
})
|
||||
}
|
||||
|
||||
func (st *Store) ResetImporting(ctx context.Context) error {
|
||||
_, err := st.pool.Exec(ctx, `UPDATE upload_files SET status='approved', updated_at=now() WHERE status='importing'`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (st *Store) SaveOutcome(ctx context.Context, f File, o outcome) error {
|
||||
return st.Transition(ctx, f.ID, []Status{StatusProcessing}, o.status, func(tx pgx.Tx) error {
|
||||
var (
|
||||
tags, metaJSON []byte
|
||||
points []int32
|
||||
)
|
||||
if o.tags != nil {
|
||||
tags, _ = json.Marshal(o.tags)
|
||||
}
|
||||
if o.meta != nil {
|
||||
metaJSON, _ = json.Marshal(o.meta)
|
||||
}
|
||||
if o.print != nil {
|
||||
points = make([]int32, len(o.print.Points))
|
||||
for i, p := range o.print.Points {
|
||||
points[i] = int32(p)
|
||||
}
|
||||
}
|
||||
p := o.probe
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE upload_files SET sha256 = coalesce($2, sha256), format = NULLIF($3, ''), codec = NULLIF($4, ''),
|
||||
bitrate = NULLIF($5, 0), sample_rate = NULLIF($6, 0), bit_depth = NULLIF($7, 0), duration_ms = NULLIF($8, 0),
|
||||
tags = $9, metadata = $10, fingerprint = $11, duplicate_of_track_id = $12, duplicate_level = NULLIF($13, ''),
|
||||
possible_duplicate = $14, quality_upgrade = $20, target_source_id = $15, target_rel_path = $16, status_reason = NULLIF($17, ''),
|
||||
matched_artist_id = $18, matched_album_id = $19
|
||||
WHERE id = $1`,
|
||||
f.ID, o.sha, p.Format, p.Codec, p.Bitrate, p.SampleRate, p.BitDepth, p.DurationMs, tags, metaJSON, points,
|
||||
o.dupTrack, o.dupLevel, o.possible, o.place.SourceID, o.place.RelPath, o.reason, o.place.ArtistID, o.place.AlbumID, o.quality)
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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)), ".")
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -17,7 +16,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/library/meta"
|
||||
@@ -114,7 +112,7 @@ type outcome struct {
|
||||
func (s *Service) process(ctx context.Context, f File) error {
|
||||
abs := s.stagingAbs(f.StagingPath)
|
||||
o := s.analyze(ctx, f, abs)
|
||||
if err := s.saveOutcome(ctx, f, o); err != nil {
|
||||
if err := s.store.SaveOutcome(ctx, f, o); err != nil {
|
||||
return err
|
||||
}
|
||||
if o.status != StatusPendingReview {
|
||||
@@ -256,38 +254,6 @@ func (s *Service) sourcePatterns(ctx context.Context, id uuid.UUID) []meta.Patte
|
||||
return patterns
|
||||
}
|
||||
|
||||
func (s *Service) saveOutcome(ctx context.Context, f File, o outcome) error {
|
||||
return s.store.Transition(ctx, f.ID, []Status{StatusProcessing}, o.status, func(tx pgx.Tx) error {
|
||||
var (
|
||||
tags, metaJSON []byte
|
||||
points []int32
|
||||
)
|
||||
if o.tags != nil {
|
||||
tags, _ = json.Marshal(o.tags)
|
||||
}
|
||||
if o.meta != nil {
|
||||
metaJSON, _ = json.Marshal(o.meta)
|
||||
}
|
||||
if o.print != nil {
|
||||
points = make([]int32, len(o.print.Points))
|
||||
for i, p := range o.print.Points {
|
||||
points[i] = int32(p)
|
||||
}
|
||||
}
|
||||
p := o.probe
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE upload_files SET sha256 = coalesce($2, sha256), format = NULLIF($3, ''), codec = NULLIF($4, ''),
|
||||
bitrate = NULLIF($5, 0), sample_rate = NULLIF($6, 0), bit_depth = NULLIF($7, 0), duration_ms = NULLIF($8, 0),
|
||||
tags = $9, metadata = $10, fingerprint = $11, duplicate_of_track_id = $12, duplicate_level = NULLIF($13, ''),
|
||||
possible_duplicate = $14, quality_upgrade = $20, target_source_id = $15, target_rel_path = $16, status_reason = NULLIF($17, ''),
|
||||
matched_artist_id = $18, matched_album_id = $19
|
||||
WHERE id = $1`,
|
||||
f.ID, o.sha, p.Format, p.Codec, p.Bitrate, p.SampleRate, p.BitDepth, p.DurationMs, tags, metaJSON, points,
|
||||
o.dupTrack, o.dupLevel, o.possible, o.place.SourceID, o.place.RelPath, o.reason, o.place.ArtistID, o.place.AlbumID, o.quality)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// --- Импорт ---
|
||||
|
||||
// importNext импортирует один одобренный файл; false — работы нет.
|
||||
@@ -300,17 +266,14 @@ func (s *Service) importNext(ctx context.Context) bool {
|
||||
s.log.Error("uploads: next to import", "err", err)
|
||||
return false
|
||||
}
|
||||
if err := s.store.Transition(ctx, f.ID, []Status{StatusApproved}, StatusImporting, nil); err != nil {
|
||||
if err := s.store.ChangeStatus(ctx, f.ID, []Status{StatusApproved}, StatusImporting); err != nil {
|
||||
// Файл успели отклонить — берём следующий; сбой БД — ждём тика.
|
||||
return errors.Is(err, ErrWrongStatus)
|
||||
}
|
||||
if err := s.importFile(ctx, f); err != nil {
|
||||
s.log.Warn("upload import failed", "upload_id", f.ID, "err", err)
|
||||
msg := err.Error()
|
||||
if ferr := s.store.Transition(ctx, f.ID, []Status{StatusImporting}, StatusImportFailed, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `UPDATE upload_files SET status_reason = $2 WHERE id = $1`, f.ID, CodeImportFailed+": "+msg)
|
||||
return err
|
||||
}); ferr != nil {
|
||||
if ferr := s.store.FailImport(ctx, f.ID, CodeImportFailed+": "+msg); ferr != nil {
|
||||
s.log.Error("uploads: mark import failed", "upload_id", f.ID, "err", ferr)
|
||||
}
|
||||
}
|
||||
@@ -360,13 +323,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
if err := s.store.Transition(ctx, f.ID, []Status{StatusImporting}, StatusImported, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `UPDATE upload_files SET target_source_id = $2, target_rel_path = $3 WHERE id = $1`,
|
||||
f.ID, pl.src.ID, rel); err != nil {
|
||||
return err
|
||||
}
|
||||
return MarkImported(ctx, tx, f.ID, fileID)
|
||||
}); err != nil {
|
||||
if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.removeStaging(f)
|
||||
@@ -439,7 +396,7 @@ func (s *Service) expire(ctx context.Context) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := s.store.Transition(ctx, id, []Status{StatusUploading}, StatusExpired, nil); err == nil {
|
||||
if err := s.store.ChangeStatus(ctx, id, []Status{StatusUploading}, StatusExpired); err == nil {
|
||||
s.removeStaging(f)
|
||||
}
|
||||
}
|
||||
@@ -447,7 +404,7 @@ func (s *Service) expire(ctx context.Context) {
|
||||
|
||||
// reconcile при старте: прерванный импорт — снова в approved; осиротевшие файлы staging удаляются.
|
||||
func (s *Service) reconcile(ctx context.Context) {
|
||||
if _, err := s.store.pool.Exec(ctx, `UPDATE upload_files SET status = 'approved', updated_at = now() WHERE status = 'importing'`); err != nil {
|
||||
if err := s.store.ResetImporting(ctx); err != nil {
|
||||
s.log.Error("uploads: reset importing", "err", err)
|
||||
}
|
||||
inUse, err := s.store.StagingInUse(ctx)
|
||||
|
||||
Reference in new issue
Block a user